Phase F
This commit is contained in:
148
apps/api/src/integrations/whmcs/whmcs-events.service.ts
Normal file
148
apps/api/src/integrations/whmcs/whmcs-events.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
24
apps/api/src/integrations/whmcs/whmcs-period.util.ts
Normal file
24
apps/api/src/integrations/whmcs/whmcs-period.util.ts
Normal 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 };
|
||||
}
|
||||
573
apps/api/src/integrations/whmcs/whmcs-reconciliation.service.ts
Normal file
573
apps/api/src/integrations/whmcs/whmcs-reconciliation.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
46
apps/api/test/whmcs-phase-ef.test.js
Normal file
46
apps/api/test/whmcs-phase-ef.test.js
Normal 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'));
|
||||
});
|
||||
});
|
||||
190
apps/worker/src/handlers/whmcs-sync.ts
Normal file
190
apps/worker/src/handlers/whmcs-sync.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
53
docs/integrations/whmcs/reconciliation.md
Normal file
53
docs/integrations/whmcs/reconciliation.md
Normal 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.
|
||||
50
docs/integrations/whmcs/usage-billing.md
Normal file
50
docs/integrations/whmcs/usage-billing.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user