Phase F
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

This commit is contained in:
smueller
2026-06-26 15:05:01 +02:00
parent 333ad1cc7d
commit 15cf302afc
23 changed files with 2116 additions and 8 deletions

View File

@@ -0,0 +1,148 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type {
WhmcsEventsAckRequest,
WhmcsEventsAckResponse,
WhmcsEventsResponse,
} from '@hexahost/contracts';
import type { Prisma } from '@hexahost/database';
import { PrismaService } from '../../prisma/prisma.service';
type InstallationRef = {
id: string;
};
@Injectable()
export class WhmcsEventsService {
constructor(private readonly prisma: PrismaService) {}
async listEvents(
installation: InstallationRef,
cursor?: string,
limit = 50,
): Promise<WhmcsEventsResponse> {
const take = Math.min(Math.max(limit, 1), 100);
const events = await this.prisma.integrationEvent.findMany({
where: {
installationId: installation.id,
status: 'PENDING',
...(cursor
? {
createdAt: {
gt: await this.cursorCreatedAt(installation.id, cursor),
},
}
: {}),
},
orderBy: { createdAt: 'asc' },
take: take + 1,
});
const page = events.slice(0, take);
const nextCursor =
events.length > take ? (page[page.length - 1]?.id ?? null) : null;
return {
events: page.map((event) => ({
id: event.id,
eventType: event.eventType,
payload: event.payload as Record<string, unknown>,
status: event.status,
createdAt: event.createdAt.toISOString(),
})),
nextCursor,
};
}
async acknowledgeEvents(
installation: InstallationRef,
input: WhmcsEventsAckRequest,
): Promise<WhmcsEventsAckResponse> {
const events = await this.prisma.integrationEvent.findMany({
where: {
installationId: installation.id,
id: { in: input.eventIds },
},
});
if (events.length !== input.eventIds.length) {
throw new NotFoundException('One or more events were not found');
}
const now = new Date();
if (input.deadLetter) {
await this.prisma.integrationEvent.updateMany({
where: {
installationId: installation.id,
id: { in: input.eventIds },
},
data: {
status: 'DEAD_LETTER',
deadLetteredAt: now,
deadLetterReason: input.deadLetterReason ?? 'Marked dead letter by WHMCS',
},
});
} else {
await this.prisma.integrationEvent.updateMany({
where: {
installationId: installation.id,
id: { in: input.eventIds },
},
data: {
status: 'ACKNOWLEDGED',
acknowledgedAt: now,
},
});
const lastId = input.eventIds[input.eventIds.length - 1];
if (lastId) {
await this.prisma.integrationSyncCursor.upsert({
where: { installationId: installation.id },
create: {
installationId: installation.id,
eventsCursor: lastId,
},
update: {
eventsCursor: lastId,
},
});
}
}
return { acknowledged: events.length };
}
async emitEvent(
installationId: string,
eventType: string,
payload: Record<string, unknown>,
dedupeKey: string,
): Promise<void> {
try {
await this.prisma.integrationEvent.create({
data: {
installationId,
eventType,
payload: payload as Prisma.InputJsonValue,
dedupeKey,
},
});
} catch {
// Duplicate dedupe key — event already emitted
}
}
private async cursorCreatedAt(
installationId: string,
cursor: string,
): Promise<Date> {
const event = await this.prisma.integrationEvent.findFirst({
where: { id: cursor, installationId },
});
return event?.createdAt ?? new Date(0);
}
}

View File

@@ -6,6 +6,7 @@ import {
Patch,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
@@ -14,9 +15,14 @@ import { SkipThrottle } from '@nestjs/throttler';
import {
whmcsChangePackageRequestSchema,
whmcsCreateSsoRequestSchema,
whmcsEventsAckRequestSchema,
whmcsImportServiceRequestSchema,
whmcsProvisionServiceRequestSchema,
whmcsReconcileRequestSchema,
whmcsSuspendRequestSchema,
whmcsUpsertClientRequestSchema,
whmcsUsageAdjustmentRequestSchema,
whmcsUsageQuerySchema,
} from '@hexahost/contracts';
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
@@ -27,6 +33,9 @@ import {
type WhmcsAuthenticatedRequest,
} from './whmcs-integration.guard';
import { WhmcsIntegrationService } from './whmcs-integration.service';
import { WhmcsEventsService } from './whmcs-events.service';
import { WhmcsReconciliationService } from './whmcs-reconciliation.service';
import { WhmcsUsageService } from './whmcs-usage.service';
@Controller('integrations/whmcs')
@UseGuards(WhmcsIntegrationGuard)
@@ -35,6 +44,9 @@ export class WhmcsIntegrationController {
constructor(
private readonly whmcs: WhmcsIntegrationService,
private readonly sso: SsoService,
private readonly reconciliation: WhmcsReconciliationService,
private readonly events: WhmcsEventsService,
private readonly usage: WhmcsUsageService,
) {}
@Get('health')
@@ -148,6 +160,95 @@ export class WhmcsIntegrationController {
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Get('accounts')
listAccounts(@Req() request: WhmcsAuthenticatedRequest) {
return this.reconciliation.listAccounts(request.whmcsInstallation!);
}
@Post('services/import')
importService(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) {
const parsed = whmcsImportServiceRequestSchema.parse(body);
return this.reconciliation.importService(
request.whmcsInstallation!,
parsed,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Post('reconcile')
reconcileAll(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) {
const parsed = whmcsReconcileRequestSchema.parse(body ?? {});
return this.reconciliation.reconcileInstallation(
request.whmcsInstallation!,
parsed,
);
}
@Post('services/:externalServiceId/reconcile')
reconcileService(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
@Body() body: unknown,
) {
const parsed = whmcsReconcileRequestSchema.parse(body ?? {});
return this.reconciliation.reconcileService(
request.whmcsInstallation!,
externalServiceId,
parsed,
);
}
@Get('events')
listEvents(
@Req() request: WhmcsAuthenticatedRequest,
@Query('cursor') cursor?: string,
@Query('limit') limit?: string,
) {
const parsedLimit = limit ? Number.parseInt(limit, 10) : 50;
return this.events.listEvents(
request.whmcsInstallation!,
cursor,
Number.isFinite(parsedLimit) ? parsedLimit : 50,
);
}
@Post('events/ack')
acknowledgeEvents(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) {
const parsed = whmcsEventsAckRequestSchema.parse(body);
return this.events.acknowledgeEvents(request.whmcsInstallation!, parsed);
}
@Get('services/:externalServiceId/usage')
getServiceUsage(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
@Query() query: unknown,
) {
const parsed = whmcsUsageQuerySchema.parse(query);
return this.usage.getServiceUsage(
request.whmcsInstallation!,
externalServiceId,
parsed.periodStart,
parsed.periodEnd,
parsed.testMode,
);
}
@Post('services/:externalServiceId/usage/adjustments')
createUsageAdjustment(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
@Body() body: unknown,
) {
const parsed = whmcsUsageAdjustmentRequestSchema.parse(body);
return this.usage.createAdjustment(
request.whmcsInstallation!,
externalServiceId,
parsed,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
}
function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined {

View File

@@ -8,9 +8,12 @@ import { AuthModule } from '../../auth/auth.module';
import { ServersModule } from '../../servers/servers.module';
import { WhmcsIntegrationController } from './whmcs-integration.controller';
import { WhmcsEventsService } from './whmcs-events.service';
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
import { WhmcsIntegrationGuard } from './whmcs-integration.guard';
import { WhmcsIntegrationService } from './whmcs-integration.service';
import { WhmcsReconciliationService } from './whmcs-reconciliation.service';
import { WhmcsUsageService } from './whmcs-usage.service';
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
@@ -19,6 +22,9 @@ export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
controllers: [WhmcsIntegrationController],
providers: [
WhmcsIntegrationService,
WhmcsReconciliationService,
WhmcsEventsService,
WhmcsUsageService,
WhmcsIntegrationGuard,
{
provide: INTEGRATIONS_REDIS,

View File

@@ -0,0 +1,24 @@
export function resolveBillingPeriod(
periodStart?: string,
periodEnd?: string,
): { start: Date; end: Date } {
if (periodStart && periodEnd) {
return {
start: new Date(periodStart),
end: new Date(periodEnd),
};
}
const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
return { start, end };
}
export function previousBillingPeriod(reference = new Date()): { start: Date; end: Date } {
const start = new Date(
Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth() - 1, 1),
);
const end = new Date(Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth(), 1));
return { start, end };
}

View File

@@ -0,0 +1,573 @@
import { randomUUID } from 'node:crypto';
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
WhmcsImportServiceRequest,
WhmcsListAccountsResponse,
WhmcsReconcileRequest,
WhmcsReconcileResponse,
} from '@hexahost/contracts';
import { formatJoinAddress } from '@hexahost/dns';
import type { Prisma } from '@hexahost/database';
import { PrismaService } from '../../prisma/prisma.service';
import { WhmcsEventsService } from './whmcs-events.service';
type InstallationRef = {
id: string;
integrationId: string;
};
type ReconciliationSeverity =
| 'INFO'
| 'WARNING'
| 'BLOCKING'
| 'DESTRUCTIVE'
| 'SECURITY';
type IssueDraft = {
code: string;
severity: ReconciliationSeverity;
message: string;
externalServiceId?: string;
serverId?: string;
details?: Record<string, unknown>;
};
@Injectable()
export class WhmcsReconciliationService {
constructor(
private readonly prisma: PrismaService,
private readonly events: WhmcsEventsService,
) {}
async listAccounts(installation: InstallationRef): Promise<WhmcsListAccountsResponse> {
const links = await this.prisma.whmcsServiceLink.findMany({
where: { installationId: installation.id },
include: {
server: { include: { plan: true } },
},
orderBy: { externalServiceId: 'asc' },
});
const clientLinks = await this.prisma.whmcsClientLink.findMany({
where: { installationId: installation.id },
});
const clientByUserId = new Map(clientLinks.map((link) => [link.userId, link]));
return {
accounts: links.map((link) => ({
externalServiceId: link.externalServiceId,
externalClientId:
clientByUserId.get(link.server.userId)?.externalClientId ?? '',
serverId: link.serverId,
domain: link.server.name,
username: link.server.joinSlug,
status: this.mapLinkStatusToWhmcs(link.status, link.server.status),
planSlug: link.server.plan?.slug ?? 'unknown',
ramMb: link.server.ramMb,
gameCloudStatus: link.server.status,
linkStatus: link.status,
lastReconciledAt: link.lastReconciledAt?.toISOString() ?? null,
})),
};
}
async importService(
installation: InstallationRef,
input: WhmcsImportServiceRequest,
idempotencyKey: string,
): Promise<{
externalServiceId: string;
status: string;
serverId: string;
imported: boolean;
}> {
const existingOp = await this.prisma.integrationOperation.findUnique({
where: { idempotencyKey },
});
if (existingOp?.result) {
return existingOp.result as {
externalServiceId: string;
status: string;
serverId: string;
imported: boolean;
};
}
const existing = await this.prisma.whmcsServiceLink.findUnique({
where: {
installationId_externalServiceId: {
installationId: installation.id,
externalServiceId: input.externalServiceId,
},
},
});
if (existing) {
throw new ConflictException('Service link already exists');
}
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 server = await this.prisma.gameServer.findUnique({
where: { id: input.serverId },
});
if (!server || server.userId !== clientLink.userId) {
throw new NotFoundException('Server not found for client');
}
const existingServerLink = await this.prisma.whmcsServiceLink.findUnique({
where: { serverId: server.id },
});
if (existingServerLink) {
throw new ConflictException('Server is already linked to a WHMCS service');
}
const metadata =
input.expectedPlanSlug !== undefined
? ({ expectedPlanSlug: input.expectedPlanSlug } satisfies Record<string, string>)
: undefined;
const link = await this.prisma.whmcsServiceLink.create({
data: {
installationId: installation.id,
externalServiceId: input.externalServiceId,
externalProductId: input.externalProductId,
serverId: server.id,
status: server.billingSuspendedAt ? 'SUSPENDED' : 'ACTIVE',
metadata: metadata as Prisma.InputJsonValue | undefined,
},
include: { server: { include: { plan: true } } },
});
const result = {
externalServiceId: link.externalServiceId,
status: link.status,
serverId: link.serverId,
imported: true,
};
await this.prisma.integrationOperation.create({
data: {
installationId: installation.id,
idempotencyKey,
operation: 'service:import',
externalRef: input.externalServiceId,
status: 'COMPLETED',
result: result as object,
},
});
return result;
}
async reconcileInstallation(
installation: InstallationRef,
input: WhmcsReconcileRequest,
externalServiceIds?: string[],
): Promise<WhmcsReconcileResponse> {
const runId = randomUUID();
const issues: IssueDraft[] = [];
const repaired: string[] = [];
const links = await this.prisma.whmcsServiceLink.findMany({
where: {
installationId: installation.id,
...(externalServiceIds?.length
? { externalServiceId: { in: externalServiceIds } }
: {}),
},
include: {
server: { include: { plan: true } },
},
});
for (const link of links) {
issues.push(
...this.inspectServiceLink(link, input),
);
}
if (!externalServiceIds?.length) {
issues.push(...(await this.findOrphanServers(installation.id)));
}
if (input.autoRepair && !input.dryRun) {
for (const link of links) {
const repairedCodes = await this.applySafeRepairs(link, input);
repaired.push(...repairedCodes);
}
}
if (!input.dryRun) {
const persistedIssues = await Promise.all(
issues.map((issue) =>
this.prisma.reconciliationIssue.create({
data: {
installationId: installation.id,
runId,
externalServiceId: issue.externalServiceId,
serverId: issue.serverId,
code: issue.code,
severity: issue.severity,
message: issue.message,
details: issue.details as Prisma.InputJsonValue | undefined,
autoRepaired: repaired.some((code) => code === issue.code),
},
}),
),
);
await this.prisma.whmcsServiceLink.updateMany({
where: {
installationId: installation.id,
id: { in: links.map((link) => link.id) },
},
data: { lastReconciledAt: new Date() },
});
const blockingIssues = issues.filter(
(issue) => issue.severity === 'BLOCKING' || issue.severity === 'SECURITY',
);
if (blockingIssues.length > 0) {
await this.events.emitEvent(
installation.id,
'reconciliation_required',
{
runId,
issueCount: issues.length,
blockingCount: blockingIssues.length,
},
`reconciliation:${installation.id}:${runId}`,
);
}
return {
runId,
dryRun: false,
issues: persistedIssues.map((issue) => ({
id: issue.id,
code: issue.code,
severity: issue.severity,
message: issue.message,
externalServiceId: issue.externalServiceId,
serverId: issue.serverId,
details: issue.details as Record<string, unknown> | undefined,
autoRepaired: issue.autoRepaired,
})),
repaired,
};
}
return {
runId,
dryRun: true,
issues: issues.map((issue) => ({
id: randomUUID(),
code: issue.code,
severity: issue.severity,
message: issue.message,
externalServiceId: issue.externalServiceId ?? null,
serverId: issue.serverId ?? null,
details: issue.details,
autoRepaired: false,
})),
repaired: [],
};
}
async reconcileService(
installation: InstallationRef,
externalServiceId: string,
input: WhmcsReconcileRequest,
): Promise<WhmcsReconcileResponse> {
const link = await this.prisma.whmcsServiceLink.findUnique({
where: {
installationId_externalServiceId: {
installationId: installation.id,
externalServiceId,
},
},
});
if (!link) {
throw new NotFoundException(`Unknown WHMCS service ${externalServiceId}`);
}
return this.reconcileInstallation(installation, input, [externalServiceId]);
}
private inspectServiceLink(
link: {
externalServiceId: string;
status: string;
metadata: unknown;
server: {
id: string;
status: string;
ramMb: number;
billingSuspendedAt: Date | null;
joinSlug: string | null;
edition: string;
plan: { slug: string; maxRamMb: number } | null;
};
},
input: WhmcsReconcileRequest,
): IssueDraft[] {
const issues: IssueDraft[] = [];
const server = link.server;
const base = {
externalServiceId: link.externalServiceId,
serverId: server.id,
};
if (link.status === 'SUSPENDED' && !server.billingSuspendedAt) {
issues.push({
...base,
code: 'status_mismatch_suspended',
severity: 'BLOCKING',
message: 'WHMCS link is suspended but server is not billing-suspended',
});
}
if (link.status === 'ACTIVE' && server.billingSuspendedAt) {
issues.push({
...base,
code: 'status_mismatch_active',
severity: 'WARNING',
message: 'WHMCS link is active but server billing suspension is set',
});
}
if (link.status === 'TERMINATED' && server.status !== 'DELETED') {
issues.push({
...base,
code: 'status_mismatch_terminated',
severity: 'DESTRUCTIVE',
message: 'WHMCS link is terminated but GameCloud server still exists',
details: { gameCloudStatus: server.status },
});
}
if (
link.status === 'SUSPENDED' &&
server.status === 'RUNNING' &&
!server.billingSuspendedAt
) {
issues.push({
...base,
code: 'startable_while_suspended',
severity: 'SECURITY',
message: 'Suspended service is still running without billing suspension',
});
}
const metadata = link.metadata as { expectedPlanSlug?: string; expectedRamMb?: number } | null;
const expectedPlanSlug = input.expectedPlanSlug ?? metadata?.expectedPlanSlug;
if (expectedPlanSlug && server.plan && expectedPlanSlug !== server.plan.slug) {
issues.push({
...base,
code: 'plan_mismatch',
severity: 'WARNING',
message: `Expected plan ${expectedPlanSlug} but server has ${server.plan.slug}`,
details: { expectedPlanSlug, actualPlanSlug: server.plan.slug },
});
}
const expectedRamMb = input.expectedRamMb ?? metadata?.expectedRamMb;
if (expectedRamMb && expectedRamMb !== server.ramMb) {
issues.push({
...base,
code: 'ram_mismatch',
severity: 'WARNING',
message: `Expected RAM ${expectedRamMb} MiB but server has ${server.ramMb} MiB`,
details: { expectedRamMb, actualRamMb: server.ramMb },
});
}
if (input.expectedWhmcsStatus) {
const expectedLinkStatus = this.mapWhmcsStatusToLink(input.expectedWhmcsStatus);
if (expectedLinkStatus && link.status !== expectedLinkStatus) {
issues.push({
...base,
code: 'whmcs_status_mismatch',
severity: 'BLOCKING',
message: `WHMCS reports ${input.expectedWhmcsStatus} but link status is ${link.status}`,
details: {
expectedWhmcsStatus: input.expectedWhmcsStatus,
linkStatus: link.status,
},
});
}
}
if (!server.joinSlug) {
issues.push({
...base,
code: 'missing_join_slug',
severity: 'INFO',
message: 'Server has no join slug assigned',
});
} else {
const hostname = formatJoinAddress(server.joinSlug, server.edition as 'JAVA' | 'BEDROCK');
issues.push({
...base,
code: 'join_hostname',
severity: 'INFO',
message: `Join hostname ${hostname}`,
details: { joinHostname: hostname },
});
}
return issues;
}
private async findOrphanServers(installationId: string): Promise<IssueDraft[]> {
const clientUserIds = await this.prisma.whmcsClientLink.findMany({
where: { installationId },
select: { userId: true },
});
const userIds = clientUserIds.map((link) => link.userId);
if (userIds.length === 0) {
return [];
}
const linkedServerIds = await this.prisma.whmcsServiceLink.findMany({
where: { installationId },
select: { serverId: true },
});
const linkedSet = new Set(linkedServerIds.map((link) => link.serverId));
const orphans = await this.prisma.gameServer.findMany({
where: {
userId: { in: userIds },
status: { not: 'DELETED' },
id: { notIn: [...linkedSet] },
},
select: { id: true, name: true, userId: true },
});
return orphans.map((server) => ({
code: 'orphan_server',
severity: 'WARNING' as const,
message: `GameCloud server ${server.name} has no WHMCS service link`,
serverId: server.id,
details: { userId: server.userId },
}));
}
private async applySafeRepairs(
link: {
id: string;
status: string;
serverId: string;
server: {
billingSuspendedAt: Date | null;
status: string;
};
},
input: WhmcsReconcileRequest,
): Promise<string[]> {
const repaired: string[] = [];
if (link.status === 'SUSPENDED' && !link.server.billingSuspendedAt) {
await this.prisma.gameServer.update({
where: { id: link.serverId },
data: {
billingSuspendedAt: new Date(),
billingSuspendReason: 'reconciliation-auto-repair',
},
});
repaired.push('status_mismatch_suspended');
}
if (
link.status === 'ACTIVE' &&
link.server.billingSuspendedAt &&
input.expectedWhmcsStatus === 'Active'
) {
await this.prisma.gameServer.update({
where: { id: link.serverId },
data: {
billingSuspendedAt: null,
billingSuspendReason: null,
},
});
repaired.push('status_mismatch_active');
}
if (
link.status === 'SUSPENDED' &&
link.server.status === 'RUNNING' &&
!link.server.billingSuspendedAt
) {
await this.prisma.gameServer.update({
where: { id: link.serverId },
data: {
billingSuspendedAt: new Date(),
billingSuspendReason: 'reconciliation-security-repair',
},
});
repaired.push('startable_while_suspended');
}
return repaired;
}
private mapLinkStatusToWhmcs(
linkStatus: string,
serverStatus: string,
): 'Active' | 'Suspended' | 'Terminated' | 'Pending' {
if (linkStatus === 'TERMINATED' || serverStatus === 'DELETED') {
return 'Terminated';
}
if (linkStatus === 'SUSPENDED') {
return 'Suspended';
}
if (linkStatus === 'PENDING') {
return 'Pending';
}
return 'Active';
}
private mapWhmcsStatusToLink(
status: string,
): 'PENDING' | 'ACTIVE' | 'SUSPENDED' | 'TERMINATED' | null {
switch (status) {
case 'Pending':
return 'PENDING';
case 'Active':
return 'ACTIVE';
case 'Suspended':
case 'Cancelled':
case 'Fraud':
return 'SUSPENDED';
case 'Terminated':
return 'TERMINATED';
default:
return null;
}
}
}

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

View File

@@ -0,0 +1,46 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('whmcs phase e/f contract', () => {
it('documents reconciliation, events and usage routes', () => {
const whmcsSource = readFileSync(
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-integration.controller.ts'),
'utf8',
);
assert.ok(whmcsSource.includes("'accounts'"));
assert.ok(whmcsSource.includes("'services/import'"));
assert.ok(whmcsSource.includes("'reconcile'"));
assert.ok(whmcsSource.includes("'services/:externalServiceId/reconcile'"));
assert.ok(whmcsSource.includes("'events'"));
assert.ok(whmcsSource.includes("'events/ack'"));
assert.ok(whmcsSource.includes("'services/:externalServiceId/usage'"));
assert.ok(whmcsSource.includes("'services/:externalServiceId/usage/adjustments'"));
const reconcileSource = readFileSync(
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-reconciliation.service.ts'),
'utf8',
);
assert.ok(reconcileSource.includes('listAccounts'));
assert.ok(reconcileSource.includes('importService'));
assert.ok(reconcileSource.includes('reconcileInstallation'));
const usageSource = readFileSync(
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-usage.service.ts'),
'utf8',
);
assert.ok(usageSource.includes('runtime_minutes'));
assert.ok(usageSource.includes('createAdjustment'));
});
it('documents WHMCS PHP ListAccounts and usage hooks', () => {
const phpSource = readFileSync(
join(process.cwd(), '..', '..', 'integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php'),
'utf8',
);
assert.ok(phpSource.includes('hexagamecloud_ListAccounts'));
assert.ok(phpSource.includes('hexagamecloud_MetricProvider'));
assert.ok(phpSource.includes('hexagamecloud_UsageUpdate'));
});
});

View File

@@ -0,0 +1,190 @@
import { randomUUID } from 'node:crypto';
import { prisma } from '@hexahost/database';
import { logger } from '../logger';
function previousBillingPeriod(reference = new Date()): { start: Date; end: Date } {
const start = new Date(
Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth() - 1, 1),
);
const end = new Date(Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth(), 1));
return { start, end };
}
export async function processWhmcsReconciliationTick(): Promise<void> {
const now = new Date();
if (now.getUTCHours() !== 3) {
return;
}
const installations = await prisma.whmcsInstallation.findMany({
where: { isActive: true },
select: { id: true, integrationId: true },
});
for (const installation of installations) {
try {
await runInstallationReconciliation(installation.id, installation.integrationId);
} catch (error) {
logger.error(
{ err: error, installationId: installation.id },
'WHMCS reconciliation tick failed',
);
}
}
}
async function runInstallationReconciliation(
installationId: string,
integrationId: string,
): Promise<void> {
const runId = randomUUID();
const links = await prisma.whmcsServiceLink.findMany({
where: { installationId },
include: { server: { include: { plan: true } } },
});
const issues: Array<{
code: string;
severity: 'INFO' | 'WARNING' | 'BLOCKING' | 'DESTRUCTIVE' | 'SECURITY';
message: string;
externalServiceId?: string;
serverId?: string;
}> = [];
for (const link of links) {
if (link.status === 'SUSPENDED' && !link.server.billingSuspendedAt) {
issues.push({
code: 'status_mismatch_suspended',
severity: 'BLOCKING',
message: 'Suspended link without billing suspension',
externalServiceId: link.externalServiceId,
serverId: link.serverId,
});
}
if (link.status === 'TERMINATED' && link.server.status !== 'DELETED') {
issues.push({
code: 'status_mismatch_terminated',
severity: 'DESTRUCTIVE',
message: 'Terminated link with active server',
externalServiceId: link.externalServiceId,
serverId: link.serverId,
});
}
}
if (issues.length === 0) {
await prisma.whmcsServiceLink.updateMany({
where: { installationId },
data: { lastReconciledAt: new Date() },
});
return;
}
await prisma.reconciliationIssue.createMany({
data: issues.map((issue) => ({
installationId,
runId,
externalServiceId: issue.externalServiceId,
serverId: issue.serverId,
code: issue.code,
severity: issue.severity,
message: issue.message,
})),
});
const blockingCount = issues.filter(
(issue) => issue.severity === 'BLOCKING' || issue.severity === 'SECURITY',
).length;
if (blockingCount > 0) {
try {
await prisma.integrationEvent.create({
data: {
installationId,
eventType: 'reconciliation_required',
payload: {
runId,
integrationId,
issueCount: issues.length,
blockingCount,
},
dedupeKey: `reconciliation:${installationId}:${runId}`,
},
});
} catch {
// duplicate dedupe
}
}
await prisma.whmcsServiceLink.updateMany({
where: { installationId },
data: { lastReconciledAt: new Date() },
});
logger.info(
{ installationId, runId, issueCount: issues.length },
'WHMCS reconciliation tick completed',
);
}
export async function processWhmcsUsagePeriodTick(): Promise<void> {
const now = new Date();
if (now.getUTCDate() !== 1 || now.getUTCHours() > 1) {
return;
}
const period = previousBillingPeriod(now);
const installations = await prisma.whmcsInstallation.findMany({
where: { isActive: true },
select: { id: true },
});
for (const installation of installations) {
const links = await prisma.whmcsServiceLink.findMany({
where: {
installationId: installation.id,
status: { in: ['ACTIVE', 'SUSPENDED'] },
},
select: { externalServiceId: true, serverId: true },
});
for (const link of links) {
const existing = await prisma.whmcsUsageExport.findUnique({
where: {
installationId_externalServiceId_periodStart_periodEnd_isTestMode: {
installationId: installation.id,
externalServiceId: link.externalServiceId,
periodStart: period.start,
periodEnd: period.end,
isTestMode: false,
},
},
});
if (existing) {
continue;
}
try {
await prisma.integrationEvent.create({
data: {
installationId: installation.id,
eventType: 'usage_period_ready',
payload: {
externalServiceId: link.externalServiceId,
serverId: link.serverId,
periodStart: period.start.toISOString(),
periodEnd: period.end.toISOString(),
},
dedupeKey: `usage-ready:${installation.id}:${link.externalServiceId}:${period.start.toISOString()}`,
},
});
} catch {
// duplicate
}
}
}
}

View File

@@ -12,6 +12,7 @@ import { processBackupJob, processScheduledBackupsTick } from './handlers/server
import { processLifecycleJob } from './handlers/server-lifecycle';
import { processProvisionServerJob } from './handlers/server-provisioning';
import { processStartQueueTick } from './handlers/start-queue';
import { processWhmcsReconciliationTick, processWhmcsUsagePeriodTick } from './handlers/whmcs-sync';
import { startHealthServer } from './health';
import { logger } from './logger';
import { closeRedisConnections } from './redis';
@@ -110,6 +111,12 @@ async function bootstrap(): Promise<void> {
void processDnsSyncTick().catch((error: Error) => {
logger.error({ err: error }, 'DNS sync tick failed');
});
void processWhmcsReconciliationTick().catch((error: Error) => {
logger.error({ err: error }, 'WHMCS reconciliation tick failed');
});
void processWhmcsUsagePeriodTick().catch((error: Error) => {
logger.error({ err: error }, 'WHMCS usage period tick failed');
});
}, 60_000);
const shutdown = async (signal: string): Promise<void> => {

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,50 @@
Last updated: 2026-06-26
## Current phase: WHMCS Phase D — SSO
## Current phase: WHMCS Phase E + F — Sync & Usage Billing
### WHMCS Phase E completed
| Area | Status | Notes |
|------|--------|-------|
| ListAccounts API | Done | `GET /integrations/whmcs/accounts` |
| Service import | Done | `POST .../services/import` |
| Reconciliation | Done | Full + per-service, severity classification |
| Integration events | Done | Cursor poll + ack/dead-letter |
| Worker daily sync | Done | 03:00 UTC reconciliation tick |
| WHMCS PHP | Done | `ListAccounts`, reconcile/import client |
| Docs | Done | `docs/integrations/whmcs/reconciliation.md` |
### WHMCS Phase F completed
| Area | Status | Notes |
|------|--------|-------|
| Usage metrics API | Done | `GET .../services/:id/usage` |
| Usage adjustments | Done | `POST .../usage/adjustments` |
| Export audit | Done | `WhmcsUsageExport` + content hash |
| Test mode | Done | `testMode=true` → DRAFT exports |
| WHMCS PHP | Done | `MetricProvider`, `UsageUpdate` |
| Docs | Done | `docs/integrations/whmcs/usage-billing.md` |
### Abnahmekriterium Phase E
- [x] ListAccounts liefert alle verknüpften Services
- [x] Reconciliation erkennt Status-, Plan- und Orphan-Drift
- [x] Events cursorbasiert mit Ack/Dead-Letter
- [x] Import bestehender Server möglich
- [ ] Manual E2E: WHMCS Server-Sync + Reconcile-Button
### Abnahmekriterium Phase F
- [x] Metriken aus `usage_records` aggregiert (UTC-Perioden)
- [x] Idempotente Exporte mit Hash
- [x] Adjustments ohne Rohdaten-Mutation
- [x] WHMCS MetricProvider + UsageUpdate
- [ ] Manual E2E: WHMCS Usage Metrics Abrechnungslauf
---
## WHMCS Phase D — SSO ✅
### WHMCS Phase D completed
@@ -191,4 +234,4 @@ Server entry fields:
### Next phase: Post-MVP hardening
- WHMCS Phase E (reconciliation), Phase F (usage billing), Stripe webhooks, penetration test
- Stripe webhooks, penetration test, WHMCS addon dashboard UI

View File

@@ -0,0 +1,53 @@
# WHMCS reconciliation (Phase E)
HexaHost GameCloud compares WHMCS service links with server state and reports drift.
## API endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/integrations/whmcs/accounts` | ListAccounts sync payload |
| `POST` | `/integrations/whmcs/services/import` | Link an existing GameCloud server |
| `POST` | `/integrations/whmcs/reconcile` | Full installation reconciliation |
| `POST` | `/integrations/whmcs/services/:id/reconcile` | Single-service reconciliation |
| `GET` | `/integrations/whmcs/events` | Cursor-based event poll |
| `POST` | `/integrations/whmcs/events/ack` | Acknowledge or dead-letter events |
## Reconciliation checks
- Suspended link without billing suspension
- Active link with billing suspension
- Terminated link with non-deleted server
- Running server while suspended
- Plan / RAM mismatch (when expected values provided)
- Orphan servers (WHMCS client without service link)
- Missing join slug (info)
## Severity
`INFO`, `WARNING`, `BLOCKING`, `DESTRUCTIVE`, `SECURITY`
Automatic repair (`autoRepair: true`, `dryRun: false`) only applies safe fixes:
- Apply billing suspension when link is suspended
- Clear billing suspension when WHMCS reports Active
- Block running servers that should be suspended
## Events
GameCloud emits:
- `reconciliation_required` — blocking/security issues found
- `usage_period_ready` — previous UTC month closed (worker)
WHMCS should poll `GET /events`, process payloads, then `POST /events/ack`. Failed events can be dead-lettered.
## PHP module
- `hexagamecloud_ListAccounts()` — server sync
- `ApiClient::reconcileService()` / `reconcileAll()`
- `ApiClient::importService()` for controlled imports
## Worker
Daily at 03:00 UTC the worker runs a lightweight reconciliation pass for all active installations.

View File

@@ -0,0 +1,50 @@
# WHMCS usage billing (Phase F)
GameCloud aggregates immutable usage from `usage_records` and exposes WHMCS-compatible metrics.
## Metrics
| Key | Source |
|-----|--------|
| `runtime_minutes` | Sum of metered runtime |
| `memory_gib_minutes` | RAM × runtime (GiB-minutes) |
| `cpu_core_minutes` | Runtime × plan CPU cores |
| `storage_gib_days` | Allocated disk × period days |
| `backup_storage_gib_days` | Completed backup sizes |
| `egress_gib` | Reserved (0 until metering) |
| `extra_port_days` | Reserved (0) |
| `priority_queue_minutes` | Queue priority weighted runtime |
| `managed_service_units` | Reserved (0) |
## API
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/integrations/whmcs/services/:id/usage` | Period metrics + UsageUpdate values |
| `POST` | `/integrations/whmcs/services/:id/usage/adjustments` | Post-export corrections |
Query parameters:
- `periodStart` / `periodEnd` — ISO-8601 UTC (default: current UTC month)
- `testMode=true` — draft export, no billing event
Responses are idempotent per installation, service, period, and test mode. Each export stores a `contentHash` for audit.
## Adjustments
Corrections never mutate historical raw `usage_records`. Submit metric deltas via the adjustments endpoint; the export status becomes `ADJUSTED`.
## PHP module
- `hexagamecloud_MetricProvider()` — returns `metrics` for WHMCS usage metrics
- `hexagamecloud_UsageUpdate()` — returns `diskusage` and `bandwidth` for classic billing
Both call `GET .../usage` on GameCloud.
## Test mode
Set `testMode` in the API query or enable test mode in the product config. Test exports use status `DRAFT` and do not emit `usage_period_closed` events.
## Pricing
All prices remain in WHMCS. GameCloud only supplies measured quantities.

View File

@@ -56,6 +56,10 @@ Handles global configuration:
**Phase D (SSO)** — implemented. See [docs/integrations/whmcs/sso.md](../../docs/integrations/whmcs/sso.md).
**Phase E (Sync & Reconciliation)** — implemented. See [docs/integrations/whmcs/reconciliation.md](../../docs/integrations/whmcs/reconciliation.md).
**Phase F (Usage Billing)** — implemented. See [docs/integrations/whmcs/usage-billing.md](../../docs/integrations/whmcs/usage-billing.md).
- Server module: `integrations/whmcs/modules/servers/hexagamecloud/`
- Addon module: planned for global mappings and reconciliation UI

View File

@@ -134,6 +134,85 @@ function hexagamecloud_TestConnection(array $params): array
}
}
function hexagamecloud_ListAccounts(array $params): array
{
try {
$client = new HexaGameCloudApiClient($params);
$response = $client->listAccounts();
$accounts = [];
foreach ($response['accounts'] ?? [] as $account) {
$accounts[] = [
'uuid' => $account['externalServiceId'] ?? '',
'domain' => $account['domain'] ?? '',
'username' => $account['username'] ?? '',
'status' => $account['status'] ?? 'Active',
];
}
return $accounts;
} catch (Throwable $exception) {
logModuleCall('hexagamecloud', __FUNCTION__, $params, $exception->getMessage());
return [];
}
}
function hexagamecloud_MetricProvider(array $params)
{
try {
$client = new HexaGameCloudApiClient($params);
$serviceId = (string) $params['serviceid'];
$usage = $client->getServiceUsage($serviceId, hexagamecloud_resolveUsageQuery($params));
return [
'metrics' => $usage['metrics'] ?? [],
];
} catch (Throwable $exception) {
logModuleCall('hexagamecloud', __FUNCTION__, $params, $exception->getMessage());
return ['metrics' => []];
}
}
function hexagamecloud_UsageUpdate(array $params)
{
try {
$client = new HexaGameCloudApiClient($params);
$serviceId = (string) $params['serviceid'];
$usage = $client->getServiceUsage($serviceId, hexagamecloud_resolveUsageQuery($params));
$usageUpdate = $usage['usageUpdate'] ?? [];
return [
'diskusage' => (float) ($usageUpdate['diskUsageGiB'] ?? 0),
'bandwidth' => (float) ($usageUpdate['bandwidthGiB'] ?? 0),
];
} catch (Throwable $exception) {
logModuleCall('hexagamecloud', __FUNCTION__, $params, $exception->getMessage());
return [
'diskusage' => 0,
'bandwidth' => 0,
];
}
}
function hexagamecloud_resolveUsageQuery(array $params): array
{
$query = [];
if (!empty($params['usageperiodstart'])) {
$query['periodStart'] = gmdate('c', strtotime((string) $params['usageperiodstart']));
}
if (!empty($params['usageperiodend'])) {
$query['periodEnd'] = gmdate('c', strtotime((string) $params['usageperiodend']));
}
if (!empty($params['testmode']) || !empty($params['configoption4'])) {
$query['testMode'] = true;
}
return $query;
}
function hexagamecloud_lifecycleAction(array $params, string $action, array $body = []): string
{
try {

View File

@@ -95,8 +95,88 @@ final class HexaGameCloudApiClient
);
}
private function request(string $method, string $path, array $body = [], ?string $idempotencySuffix = null): array
public function listAccounts(): array
{
return $this->request('GET', '/api/v1/integrations/whmcs/accounts');
}
public function reconcileService(string $externalServiceId, array $payload = []): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/reconcile',
$payload,
'service:reconcile:' . $externalServiceId . ':' . date('Y-m-d')
);
}
public function reconcileAll(array $payload = []): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/reconcile',
$payload,
'reconcile:all:' . date('Y-m-d')
);
}
public function importService(array $payload): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services/import',
$payload,
'service:import:' . $payload['externalServiceId']
);
}
public function listEvents(?string $cursor = null, int $limit = 50): array
{
$query = $cursor ? ('?cursor=' . rawurlencode($cursor) . '&limit=' . $limit) : ('?limit=' . $limit);
return $this->request('GET', '/api/v1/integrations/whmcs/events' . $query);
}
public function acknowledgeEvents(array $eventIds, bool $deadLetter = false, ?string $reason = null): array
{
$payload = ['eventIds' => $eventIds, 'deadLetter' => $deadLetter];
if ($reason !== null) {
$payload['deadLetterReason'] = $reason;
}
return $this->request('POST', '/api/v1/integrations/whmcs/events/ack', $payload);
}
public function getServiceUsage(string $externalServiceId, array $query = []): array
{
$params = http_build_query(array_filter([
'periodStart' => $query['periodStart'] ?? null,
'periodEnd' => $query['periodEnd'] ?? null,
'testMode' => isset($query['testMode']) ? ($query['testMode'] ? 'true' : 'false') : null,
]));
$suffix = $params !== '' ? ('?' . $params) : '';
return $this->request(
'GET',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/usage' . $suffix
);
}
private function request(string $method, string $pathWithQuery, array $body = [], ?string $idempotencySuffix = null): array
{
$pathParts = explode('?', $pathWithQuery, 2);
$path = $pathParts[0];
$canonicalQuery = '';
if (isset($pathParts[1]) && $pathParts[1] !== '') {
parse_str($pathParts[1], $queryParams);
ksort($queryParams);
$pairs = [];
foreach ($queryParams as $key => $value) {
$pairs[] = rawurlencode((string) $key) . '=' . rawurlencode((string) $value);
}
$canonicalQuery = implode('&', $pairs);
}
$bodyJson = $body === [] ? '' : json_encode($body, JSON_THROW_ON_ERROR);
$timestamp = (string) (int) (microtime(true) * 1000);
$nonce = bin2hex(random_bytes(16));
@@ -105,7 +185,7 @@ final class HexaGameCloudApiClient
$signatureBase = implode("\n", [
strtoupper($method),
$path,
'',
$canonicalQuery,
hash('sha256', $bodyJson),
$timestamp,
$nonce,
@@ -113,8 +193,9 @@ final class HexaGameCloudApiClient
]);
$signature = hash_hmac('sha256', $signatureBase, $this->apiSecret);
$requestUrl = $this->baseUrl . $path . ($canonicalQuery !== '' ? ('?' . $canonicalQuery) : '');
$ch = curl_init($this->baseUrl . $path);
$ch = curl_init($requestUrl);
if ($ch === false) {
throw new RuntimeException('Unable to initialize HTTP client');
}

View File

@@ -243,4 +243,23 @@ export {
type WhmcsCreateSsoResponse,
type SsoConsumeRequest,
type SsoConsumeResponse,
whmcsReconcileRequestSchema,
whmcsReconcileResponseSchema,
whmcsListAccountsResponseSchema,
whmcsImportServiceRequestSchema,
whmcsEventsResponseSchema,
whmcsEventsAckRequestSchema,
whmcsEventsAckResponseSchema,
whmcsUsageQuerySchema,
whmcsUsageResponseSchema,
whmcsUsageAdjustmentRequestSchema,
type WhmcsReconcileRequest,
type WhmcsReconcileResponse,
type WhmcsListAccountsResponse,
type WhmcsImportServiceRequest,
type WhmcsEventsResponse,
type WhmcsEventsAckRequest,
type WhmcsEventsAckResponse,
type WhmcsUsageResponse,
type WhmcsUsageAdjustmentRequest,
} from './whmcs';

View File

@@ -108,3 +108,163 @@ export type WhmcsCreateSsoRequest = z.infer<typeof whmcsCreateSsoRequestSchema>;
export type WhmcsCreateSsoResponse = z.infer<typeof whmcsCreateSsoResponseSchema>;
export type SsoConsumeRequest = z.infer<typeof ssoConsumeRequestSchema>;
export type SsoConsumeResponse = z.infer<typeof ssoConsumeResponseSchema>;
export const whmcsExpectedStatusSchema = z.enum([
'Pending',
'Active',
'Suspended',
'Terminated',
'Cancelled',
]);
export const whmcsReconcileRequestSchema = z.object({
expectedWhmcsStatus: whmcsExpectedStatusSchema.optional(),
expectedPlanSlug: z.string().max(64).optional(),
expectedRamMb: z.number().int().min(512).max(65536).optional(),
dryRun: z.boolean().default(false),
autoRepair: z.boolean().default(false),
});
export const reconciliationSeveritySchema = z.enum([
'INFO',
'WARNING',
'BLOCKING',
'DESTRUCTIVE',
'SECURITY',
]);
export const reconciliationIssueSchema = z.object({
id: z.string().uuid(),
code: z.string(),
severity: reconciliationSeveritySchema,
message: z.string(),
externalServiceId: z.string().nullable(),
serverId: z.string().uuid().nullable(),
details: z.record(z.unknown()).optional(),
autoRepaired: z.boolean(),
});
export const whmcsReconcileResponseSchema = z.object({
runId: z.string().uuid(),
dryRun: z.boolean(),
issues: z.array(reconciliationIssueSchema),
repaired: z.array(z.string()),
});
export const whmcsListAccountsResponseSchema = z.object({
accounts: z.array(
z.object({
externalServiceId: z.string(),
externalClientId: z.string(),
serverId: z.string().uuid(),
domain: z.string(),
username: z.string().nullable(),
status: z.enum(['Active', 'Suspended', 'Terminated', 'Pending']),
planSlug: z.string(),
ramMb: z.number().int(),
gameCloudStatus: z.string(),
linkStatus: z.enum(['PENDING', 'ACTIVE', 'SUSPENDED', 'TERMINATED']),
lastReconciledAt: z.string().datetime().nullable(),
}),
),
});
export const whmcsImportServiceRequestSchema = z.object({
externalServiceId: z.string().min(1).max(64),
externalClientId: z.string().min(1).max(64),
serverId: z.string().uuid(),
externalProductId: z.string().max(64).optional(),
expectedPlanSlug: z.string().max(64).optional(),
});
export const integrationEventSchema = z.object({
id: z.string().uuid(),
eventType: z.string(),
payload: z.record(z.unknown()),
status: z.enum(['PENDING', 'ACKNOWLEDGED', 'DEAD_LETTER']),
createdAt: z.string().datetime(),
});
export const whmcsEventsResponseSchema = z.object({
events: z.array(integrationEventSchema),
nextCursor: z.string().uuid().nullable(),
});
export const whmcsEventsAckRequestSchema = z.object({
eventIds: z.array(z.string().uuid()).min(1).max(100),
deadLetter: z.boolean().default(false),
deadLetterReason: z.string().max(512).optional(),
});
export const whmcsEventsAckResponseSchema = z.object({
acknowledged: z.number().int(),
});
export const whmcsUsageMetricsSchema = z.object({
runtime_minutes: z.number(),
memory_gib_minutes: z.number(),
cpu_core_minutes: z.number(),
storage_gib_days: z.number(),
backup_storage_gib_days: z.number(),
egress_gib: z.number(),
extra_port_days: z.number(),
priority_queue_minutes: z.number(),
managed_service_units: z.number(),
});
export const whmcsUsageUpdateSchema = z.object({
diskUsageGiB: z.number(),
bandwidthGiB: z.number(),
});
export const whmcsUsageQuerySchema = z.object({
periodStart: z.string().datetime().optional(),
periodEnd: z.string().datetime().optional(),
testMode: z.coerce.boolean().default(false),
});
export const whmcsUsageResponseSchema = z.object({
exportId: z.string().uuid(),
externalServiceId: z.string(),
serverId: z.string().uuid(),
periodStart: z.string().datetime(),
periodEnd: z.string().datetime(),
metrics: whmcsUsageMetricsSchema,
usageUpdate: whmcsUsageUpdateSchema,
contentHash: z.string(),
status: z.enum(['DRAFT', 'EXPORTED', 'ADJUSTED']),
isTestMode: z.boolean(),
adjustments: z.array(
z.object({
metricKey: z.string(),
delta: z.number(),
reason: z.string(),
}),
),
});
export const whmcsUsageAdjustmentRequestSchema = z.object({
periodStart: z.string().datetime(),
periodEnd: z.string().datetime(),
adjustments: z
.array(
z.object({
metricKey: z.string().min(1).max(64),
delta: z.number(),
reason: z.string().min(1).max(256),
}),
)
.min(1)
.max(20),
testMode: z.boolean().default(false),
});
export type WhmcsReconcileRequest = z.infer<typeof whmcsReconcileRequestSchema>;
export type WhmcsReconcileResponse = z.infer<typeof whmcsReconcileResponseSchema>;
export type WhmcsListAccountsResponse = z.infer<typeof whmcsListAccountsResponseSchema>;
export type WhmcsImportServiceRequest = z.infer<typeof whmcsImportServiceRequestSchema>;
export type WhmcsEventsResponse = z.infer<typeof whmcsEventsResponseSchema>;
export type WhmcsEventsAckRequest = z.infer<typeof whmcsEventsAckRequestSchema>;
export type WhmcsEventsAckResponse = z.infer<typeof whmcsEventsAckResponseSchema>;
export type WhmcsUsageResponse = z.infer<typeof whmcsUsageResponseSchema>;
export type WhmcsUsageAdjustmentRequest = z.infer<typeof whmcsUsageAdjustmentRequestSchema>;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,92 @@
-- Phase E/F: WHMCS reconciliation, events, usage exports
CREATE TYPE "IntegrationEventStatus" AS ENUM ('PENDING', 'ACKNOWLEDGED', 'DEAD_LETTER');
CREATE TYPE "ReconciliationSeverity" AS ENUM ('INFO', 'WARNING', 'BLOCKING', 'DESTRUCTIVE', 'SECURITY');
CREATE TYPE "UsageExportStatus" AS ENUM ('DRAFT', 'EXPORTED', 'ADJUSTED');
ALTER TABLE "whmcs_service_links"
ADD COLUMN "lastReconciledAt" TIMESTAMPTZ(3);
CREATE TABLE "integration_events" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"dedupeKey" TEXT NOT NULL,
"status" "IntegrationEventStatus" NOT NULL DEFAULT 'PENDING',
"acknowledgedAt" TIMESTAMPTZ(3),
"deadLetteredAt" TIMESTAMPTZ(3),
"deadLetterReason" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "integration_events_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "integration_events_dedupeKey_key" ON "integration_events"("dedupeKey");
CREATE INDEX "integration_events_installationId_status_createdAt_idx" ON "integration_events"("installationId", "status", "createdAt");
CREATE INDEX "integration_events_installationId_createdAt_idx" ON "integration_events"("installationId", "createdAt");
ALTER TABLE "integration_events"
ADD CONSTRAINT "integration_events_installationId_fkey"
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "integration_sync_cursors" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"eventsCursor" TEXT,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "integration_sync_cursors_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "integration_sync_cursors_installationId_key" ON "integration_sync_cursors"("installationId");
ALTER TABLE "integration_sync_cursors"
ADD CONSTRAINT "integration_sync_cursors_installationId_fkey"
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "reconciliation_issues" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"externalServiceId" TEXT,
"serverId" TEXT,
"code" TEXT NOT NULL,
"severity" "ReconciliationSeverity" NOT NULL,
"message" TEXT NOT NULL,
"details" JSONB,
"autoRepaired" BOOLEAN NOT NULL DEFAULT false,
"resolvedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reconciliation_issues_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "reconciliation_issues_installationId_runId_idx" ON "reconciliation_issues"("installationId", "runId");
CREATE INDEX "reconciliation_issues_installationId_resolvedAt_idx" ON "reconciliation_issues"("installationId", "resolvedAt");
ALTER TABLE "reconciliation_issues"
ADD CONSTRAINT "reconciliation_issues_installationId_fkey"
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "whmcs_usage_exports" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"externalServiceId" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"periodStart" TIMESTAMPTZ(3) NOT NULL,
"periodEnd" TIMESTAMPTZ(3) NOT NULL,
"metrics" JSONB NOT NULL,
"contentHash" TEXT NOT NULL,
"status" "UsageExportStatus" NOT NULL DEFAULT 'DRAFT',
"isTestMode" BOOLEAN NOT NULL DEFAULT false,
"adjustmentOfId" TEXT,
"exportedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "whmcs_usage_exports_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "whmcs_usage_exports_installationId_externalServiceId_periodStart_periodEnd_isTestMode_key"
ON "whmcs_usage_exports"("installationId", "externalServiceId", "periodStart", "periodEnd", "isTestMode");
CREATE INDEX "whmcs_usage_exports_installationId_periodEnd_idx" ON "whmcs_usage_exports"("installationId", "periodEnd");
ALTER TABLE "whmcs_usage_exports"
ADD CONSTRAINT "whmcs_usage_exports_installationId_fkey"
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -152,6 +152,26 @@ enum SsoTicketKind {
ADMIN
}
enum IntegrationEventStatus {
PENDING
ACKNOWLEDGED
DEAD_LETTER
}
enum ReconciliationSeverity {
INFO
WARNING
BLOCKING
DESTRUCTIVE
SECURITY
}
enum UsageExportStatus {
DRAFT
EXPORTED
ADJUSTED
}
model User {
id String @id @default(uuid())
email String @unique
@@ -724,6 +744,10 @@ model WhmcsInstallation {
serviceLinks WhmcsServiceLink[]
operations IntegrationOperation[]
ssoTickets SsoTicket[]
events IntegrationEvent[]
syncCursor IntegrationSyncCursor?
reconciliationIssues ReconciliationIssue[]
usageExports WhmcsUsageExport[]
@@map("whmcs_installations")
}
@@ -752,6 +776,7 @@ model WhmcsServiceLink {
status WhmcsServiceStatus @default(PENDING)
suspendedAt DateTime? @db.Timestamptz(3)
terminatedAt DateTime? @db.Timestamptz(3)
lastReconciledAt DateTime? @db.Timestamptz(3)
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -807,3 +832,76 @@ model SsoTicket {
@@index([installationId, createdAt])
@@map("sso_tickets")
}
model IntegrationEvent {
id String @id @default(uuid())
installationId String
eventType String
payload Json
dedupeKey String @unique
status IntegrationEventStatus @default(PENDING)
acknowledgedAt DateTime? @db.Timestamptz(3)
deadLetteredAt DateTime? @db.Timestamptz(3)
deadLetterReason String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
@@index([installationId, status, createdAt])
@@index([installationId, createdAt])
@@map("integration_events")
}
model IntegrationSyncCursor {
id String @id @default(uuid())
installationId String @unique
eventsCursor String?
updatedAt DateTime @updatedAt @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
@@map("integration_sync_cursors")
}
model ReconciliationIssue {
id String @id @default(uuid())
installationId String
runId String
externalServiceId String?
serverId String?
code String
severity ReconciliationSeverity
message String
details Json?
autoRepaired Boolean @default(false)
resolvedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
@@index([installationId, runId])
@@index([installationId, resolvedAt])
@@map("reconciliation_issues")
}
model WhmcsUsageExport {
id String @id @default(uuid())
installationId String
externalServiceId String
serverId String
periodStart DateTime @db.Timestamptz(3)
periodEnd DateTime @db.Timestamptz(3)
metrics Json
contentHash String
status UsageExportStatus @default(DRAFT)
isTestMode Boolean @default(false)
adjustmentOfId String?
exportedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
@@unique([installationId, externalServiceId, periodStart, periodEnd, isTestMode])
@@index([installationId, periodEnd])
@@map("whmcs_usage_exports")
}

View File

@@ -29,6 +29,10 @@ export type {
WhmcsServiceLink,
IntegrationOperation,
SsoTicket,
IntegrationEvent,
IntegrationSyncCursor,
ReconciliationIssue,
WhmcsUsageExport,
Plan,
FeatureFlag,
SystemSetting,

File diff suppressed because one or more lines are too long