Phase F
This commit is contained in:
190
apps/worker/src/handlers/whmcs-sync.ts
Normal file
190
apps/worker/src/handlers/whmcs-sync.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { logger } from '../logger';
|
||||
|
||||
function previousBillingPeriod(reference = new Date()): { start: Date; end: Date } {
|
||||
const start = new Date(
|
||||
Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth() - 1, 1),
|
||||
);
|
||||
const end = new Date(Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth(), 1));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export async function processWhmcsReconciliationTick(): Promise<void> {
|
||||
const now = new Date();
|
||||
if (now.getUTCHours() !== 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const installations = await prisma.whmcsInstallation.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, integrationId: true },
|
||||
});
|
||||
|
||||
for (const installation of installations) {
|
||||
try {
|
||||
await runInstallationReconciliation(installation.id, installation.integrationId);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, installationId: installation.id },
|
||||
'WHMCS reconciliation tick failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runInstallationReconciliation(
|
||||
installationId: string,
|
||||
integrationId: string,
|
||||
): Promise<void> {
|
||||
const runId = randomUUID();
|
||||
const links = await prisma.whmcsServiceLink.findMany({
|
||||
where: { installationId },
|
||||
include: { server: { include: { plan: true } } },
|
||||
});
|
||||
|
||||
const issues: Array<{
|
||||
code: string;
|
||||
severity: 'INFO' | 'WARNING' | 'BLOCKING' | 'DESTRUCTIVE' | 'SECURITY';
|
||||
message: string;
|
||||
externalServiceId?: string;
|
||||
serverId?: string;
|
||||
}> = [];
|
||||
|
||||
for (const link of links) {
|
||||
if (link.status === 'SUSPENDED' && !link.server.billingSuspendedAt) {
|
||||
issues.push({
|
||||
code: 'status_mismatch_suspended',
|
||||
severity: 'BLOCKING',
|
||||
message: 'Suspended link without billing suspension',
|
||||
externalServiceId: link.externalServiceId,
|
||||
serverId: link.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
if (link.status === 'TERMINATED' && link.server.status !== 'DELETED') {
|
||||
issues.push({
|
||||
code: 'status_mismatch_terminated',
|
||||
severity: 'DESTRUCTIVE',
|
||||
message: 'Terminated link with active server',
|
||||
externalServiceId: link.externalServiceId,
|
||||
serverId: link.serverId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
await prisma.whmcsServiceLink.updateMany({
|
||||
where: { installationId },
|
||||
data: { lastReconciledAt: new Date() },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.reconciliationIssue.createMany({
|
||||
data: issues.map((issue) => ({
|
||||
installationId,
|
||||
runId,
|
||||
externalServiceId: issue.externalServiceId,
|
||||
serverId: issue.serverId,
|
||||
code: issue.code,
|
||||
severity: issue.severity,
|
||||
message: issue.message,
|
||||
})),
|
||||
});
|
||||
|
||||
const blockingCount = issues.filter(
|
||||
(issue) => issue.severity === 'BLOCKING' || issue.severity === 'SECURITY',
|
||||
).length;
|
||||
|
||||
if (blockingCount > 0) {
|
||||
try {
|
||||
await prisma.integrationEvent.create({
|
||||
data: {
|
||||
installationId,
|
||||
eventType: 'reconciliation_required',
|
||||
payload: {
|
||||
runId,
|
||||
integrationId,
|
||||
issueCount: issues.length,
|
||||
blockingCount,
|
||||
},
|
||||
dedupeKey: `reconciliation:${installationId}:${runId}`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// duplicate dedupe
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.whmcsServiceLink.updateMany({
|
||||
where: { installationId },
|
||||
data: { lastReconciledAt: new Date() },
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ installationId, runId, issueCount: issues.length },
|
||||
'WHMCS reconciliation tick completed',
|
||||
);
|
||||
}
|
||||
|
||||
export async function processWhmcsUsagePeriodTick(): Promise<void> {
|
||||
const now = new Date();
|
||||
if (now.getUTCDate() !== 1 || now.getUTCHours() > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const period = previousBillingPeriod(now);
|
||||
const installations = await prisma.whmcsInstallation.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const installation of installations) {
|
||||
const links = await prisma.whmcsServiceLink.findMany({
|
||||
where: {
|
||||
installationId: installation.id,
|
||||
status: { in: ['ACTIVE', 'SUSPENDED'] },
|
||||
},
|
||||
select: { externalServiceId: true, serverId: true },
|
||||
});
|
||||
|
||||
for (const link of links) {
|
||||
const existing = await prisma.whmcsUsageExport.findUnique({
|
||||
where: {
|
||||
installationId_externalServiceId_periodStart_periodEnd_isTestMode: {
|
||||
installationId: installation.id,
|
||||
externalServiceId: link.externalServiceId,
|
||||
periodStart: period.start,
|
||||
periodEnd: period.end,
|
||||
isTestMode: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.integrationEvent.create({
|
||||
data: {
|
||||
installationId: installation.id,
|
||||
eventType: 'usage_period_ready',
|
||||
payload: {
|
||||
externalServiceId: link.externalServiceId,
|
||||
serverId: link.serverId,
|
||||
periodStart: period.start.toISOString(),
|
||||
periodEnd: period.end.toISOString(),
|
||||
},
|
||||
dedupeKey: `usage-ready:${installation.id}:${link.externalServiceId}:${period.start.toISOString()}`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// duplicate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { processBackupJob, processScheduledBackupsTick } from './handlers/server
|
||||
import { processLifecycleJob } from './handlers/server-lifecycle';
|
||||
import { processProvisionServerJob } from './handlers/server-provisioning';
|
||||
import { processStartQueueTick } from './handlers/start-queue';
|
||||
import { processWhmcsReconciliationTick, processWhmcsUsagePeriodTick } from './handlers/whmcs-sync';
|
||||
import { startHealthServer } from './health';
|
||||
import { logger } from './logger';
|
||||
import { closeRedisConnections } from './redis';
|
||||
@@ -110,6 +111,12 @@ async function bootstrap(): Promise<void> {
|
||||
void processDnsSyncTick().catch((error: Error) => {
|
||||
logger.error({ err: error }, 'DNS sync tick failed');
|
||||
});
|
||||
void processWhmcsReconciliationTick().catch((error: Error) => {
|
||||
logger.error({ err: error }, 'WHMCS reconciliation tick failed');
|
||||
});
|
||||
void processWhmcsUsagePeriodTick().catch((error: Error) => {
|
||||
logger.error({ err: error }, 'WHMCS usage period tick failed');
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
|
||||
Reference in New Issue
Block a user