Phase F
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 10s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 18s

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

View File

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

View File

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

File diff suppressed because one or more lines are too long