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,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -14,9 +15,14 @@ import { SkipThrottle } from '@nestjs/throttler';
|
|||||||
import {
|
import {
|
||||||
whmcsChangePackageRequestSchema,
|
whmcsChangePackageRequestSchema,
|
||||||
whmcsCreateSsoRequestSchema,
|
whmcsCreateSsoRequestSchema,
|
||||||
|
whmcsEventsAckRequestSchema,
|
||||||
|
whmcsImportServiceRequestSchema,
|
||||||
whmcsProvisionServiceRequestSchema,
|
whmcsProvisionServiceRequestSchema,
|
||||||
|
whmcsReconcileRequestSchema,
|
||||||
whmcsSuspendRequestSchema,
|
whmcsSuspendRequestSchema,
|
||||||
whmcsUpsertClientRequestSchema,
|
whmcsUpsertClientRequestSchema,
|
||||||
|
whmcsUsageAdjustmentRequestSchema,
|
||||||
|
whmcsUsageQuerySchema,
|
||||||
} from '@hexahost/contracts';
|
} from '@hexahost/contracts';
|
||||||
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
|
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
|
||||||
|
|
||||||
@@ -27,6 +33,9 @@ import {
|
|||||||
type WhmcsAuthenticatedRequest,
|
type WhmcsAuthenticatedRequest,
|
||||||
} from './whmcs-integration.guard';
|
} from './whmcs-integration.guard';
|
||||||
import { WhmcsIntegrationService } from './whmcs-integration.service';
|
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')
|
@Controller('integrations/whmcs')
|
||||||
@UseGuards(WhmcsIntegrationGuard)
|
@UseGuards(WhmcsIntegrationGuard)
|
||||||
@@ -35,6 +44,9 @@ export class WhmcsIntegrationController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly whmcs: WhmcsIntegrationService,
|
private readonly whmcs: WhmcsIntegrationService,
|
||||||
private readonly sso: SsoService,
|
private readonly sso: SsoService,
|
||||||
|
private readonly reconciliation: WhmcsReconciliationService,
|
||||||
|
private readonly events: WhmcsEventsService,
|
||||||
|
private readonly usage: WhmcsUsageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('health')
|
@Get('health')
|
||||||
@@ -148,6 +160,95 @@ export class WhmcsIntegrationController {
|
|||||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
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 {
|
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 { ServersModule } from '../../servers/servers.module';
|
||||||
|
|
||||||
import { WhmcsIntegrationController } from './whmcs-integration.controller';
|
import { WhmcsIntegrationController } from './whmcs-integration.controller';
|
||||||
|
import { WhmcsEventsService } from './whmcs-events.service';
|
||||||
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||||
import { WhmcsIntegrationGuard } from './whmcs-integration.guard';
|
import { WhmcsIntegrationGuard } from './whmcs-integration.guard';
|
||||||
import { WhmcsIntegrationService } from './whmcs-integration.service';
|
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';
|
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
|||||||
controllers: [WhmcsIntegrationController],
|
controllers: [WhmcsIntegrationController],
|
||||||
providers: [
|
providers: [
|
||||||
WhmcsIntegrationService,
|
WhmcsIntegrationService,
|
||||||
|
WhmcsReconciliationService,
|
||||||
|
WhmcsEventsService,
|
||||||
|
WhmcsUsageService,
|
||||||
WhmcsIntegrationGuard,
|
WhmcsIntegrationGuard,
|
||||||
{
|
{
|
||||||
provide: INTEGRATIONS_REDIS,
|
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 { processLifecycleJob } from './handlers/server-lifecycle';
|
||||||
import { processProvisionServerJob } from './handlers/server-provisioning';
|
import { processProvisionServerJob } from './handlers/server-provisioning';
|
||||||
import { processStartQueueTick } from './handlers/start-queue';
|
import { processStartQueueTick } from './handlers/start-queue';
|
||||||
|
import { processWhmcsReconciliationTick, processWhmcsUsagePeriodTick } from './handlers/whmcs-sync';
|
||||||
import { startHealthServer } from './health';
|
import { startHealthServer } from './health';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { closeRedisConnections } from './redis';
|
import { closeRedisConnections } from './redis';
|
||||||
@@ -110,6 +111,12 @@ async function bootstrap(): Promise<void> {
|
|||||||
void processDnsSyncTick().catch((error: Error) => {
|
void processDnsSyncTick().catch((error: Error) => {
|
||||||
logger.error({ err: error }, 'DNS sync tick failed');
|
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);
|
}, 60_000);
|
||||||
|
|
||||||
const shutdown = async (signal: string): Promise<void> => {
|
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
|
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
|
### WHMCS Phase D completed
|
||||||
|
|
||||||
@@ -191,4 +234,4 @@ Server entry fields:
|
|||||||
|
|
||||||
### Next phase: Post-MVP hardening
|
### 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 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/`
|
- Server module: `integrations/whmcs/modules/servers/hexagamecloud/`
|
||||||
- Addon module: planned for global mappings and reconciliation UI
|
- 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
|
function hexagamecloud_lifecycleAction(array $params, string $action, array $body = []): string
|
||||||
{
|
{
|
||||||
try {
|
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);
|
$bodyJson = $body === [] ? '' : json_encode($body, JSON_THROW_ON_ERROR);
|
||||||
$timestamp = (string) (int) (microtime(true) * 1000);
|
$timestamp = (string) (int) (microtime(true) * 1000);
|
||||||
$nonce = bin2hex(random_bytes(16));
|
$nonce = bin2hex(random_bytes(16));
|
||||||
@@ -105,7 +185,7 @@ final class HexaGameCloudApiClient
|
|||||||
$signatureBase = implode("\n", [
|
$signatureBase = implode("\n", [
|
||||||
strtoupper($method),
|
strtoupper($method),
|
||||||
$path,
|
$path,
|
||||||
'',
|
$canonicalQuery,
|
||||||
hash('sha256', $bodyJson),
|
hash('sha256', $bodyJson),
|
||||||
$timestamp,
|
$timestamp,
|
||||||
$nonce,
|
$nonce,
|
||||||
@@ -113,8 +193,9 @@ final class HexaGameCloudApiClient
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$signature = hash_hmac('sha256', $signatureBase, $this->apiSecret);
|
$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) {
|
if ($ch === false) {
|
||||||
throw new RuntimeException('Unable to initialize HTTP client');
|
throw new RuntimeException('Unable to initialize HTTP client');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,4 +243,23 @@ export {
|
|||||||
type WhmcsCreateSsoResponse,
|
type WhmcsCreateSsoResponse,
|
||||||
type SsoConsumeRequest,
|
type SsoConsumeRequest,
|
||||||
type SsoConsumeResponse,
|
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';
|
} from './whmcs';
|
||||||
|
|||||||
@@ -108,3 +108,163 @@ export type WhmcsCreateSsoRequest = z.infer<typeof whmcsCreateSsoRequestSchema>;
|
|||||||
export type WhmcsCreateSsoResponse = z.infer<typeof whmcsCreateSsoResponseSchema>;
|
export type WhmcsCreateSsoResponse = z.infer<typeof whmcsCreateSsoResponseSchema>;
|
||||||
export type SsoConsumeRequest = z.infer<typeof ssoConsumeRequestSchema>;
|
export type SsoConsumeRequest = z.infer<typeof ssoConsumeRequestSchema>;
|
||||||
export type SsoConsumeResponse = z.infer<typeof ssoConsumeResponseSchema>;
|
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
|
ADMIN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum IntegrationEventStatus {
|
||||||
|
PENDING
|
||||||
|
ACKNOWLEDGED
|
||||||
|
DEAD_LETTER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReconciliationSeverity {
|
||||||
|
INFO
|
||||||
|
WARNING
|
||||||
|
BLOCKING
|
||||||
|
DESTRUCTIVE
|
||||||
|
SECURITY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UsageExportStatus {
|
||||||
|
DRAFT
|
||||||
|
EXPORTED
|
||||||
|
ADJUSTED
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -724,6 +744,10 @@ model WhmcsInstallation {
|
|||||||
serviceLinks WhmcsServiceLink[]
|
serviceLinks WhmcsServiceLink[]
|
||||||
operations IntegrationOperation[]
|
operations IntegrationOperation[]
|
||||||
ssoTickets SsoTicket[]
|
ssoTickets SsoTicket[]
|
||||||
|
events IntegrationEvent[]
|
||||||
|
syncCursor IntegrationSyncCursor?
|
||||||
|
reconciliationIssues ReconciliationIssue[]
|
||||||
|
usageExports WhmcsUsageExport[]
|
||||||
|
|
||||||
@@map("whmcs_installations")
|
@@map("whmcs_installations")
|
||||||
}
|
}
|
||||||
@@ -752,6 +776,7 @@ model WhmcsServiceLink {
|
|||||||
status WhmcsServiceStatus @default(PENDING)
|
status WhmcsServiceStatus @default(PENDING)
|
||||||
suspendedAt DateTime? @db.Timestamptz(3)
|
suspendedAt DateTime? @db.Timestamptz(3)
|
||||||
terminatedAt DateTime? @db.Timestamptz(3)
|
terminatedAt DateTime? @db.Timestamptz(3)
|
||||||
|
lastReconciledAt DateTime? @db.Timestamptz(3)
|
||||||
metadata Json?
|
metadata Json?
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
@@ -807,3 +832,76 @@ model SsoTicket {
|
|||||||
@@index([installationId, createdAt])
|
@@index([installationId, createdAt])
|
||||||
@@map("sso_tickets")
|
@@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,
|
WhmcsServiceLink,
|
||||||
IntegrationOperation,
|
IntegrationOperation,
|
||||||
SsoTicket,
|
SsoTicket,
|
||||||
|
IntegrationEvent,
|
||||||
|
IntegrationSyncCursor,
|
||||||
|
ReconciliationIssue,
|
||||||
|
WhmcsUsageExport,
|
||||||
Plan,
|
Plan,
|
||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
SystemSetting,
|
SystemSetting,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user