Implement WHMCS Addon Dashboard with API for stats and integrate billing provider logic
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 17s

This commit is contained in:
smueller
2026-06-26 15:11:29 +02:00
parent 15cf302afc
commit 4b20efe4bc
30 changed files with 1414 additions and 16 deletions

View File

@@ -0,0 +1,23 @@
export type BillingProvider = 'internal' | 'whmcs' | 'stripe';
export function getBillingProvider(): BillingProvider {
const value = process.env['BILLING_PROVIDER'] ?? 'internal';
if (value === 'whmcs' || value === 'stripe') {
return value;
}
return 'internal';
}
/** Commercial billing is handled outside the credit wallet (WHMCS or Stripe). */
export function isExternalBilling(): boolean {
const provider = getBillingProvider();
return provider === 'whmcs' || provider === 'stripe';
}
export function isWhmcsBilling(): boolean {
return getBillingProvider() === 'whmcs';
}
export function isStripeBilling(): boolean {
return getBillingProvider() === 'stripe';
}

View File

@@ -4,11 +4,13 @@ import { PrismaModule } from '../prisma/prisma.module';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';
import { StripeWebhookController } from './stripe/stripe-webhook.controller';
import { StripeWebhookService } from './stripe/stripe-webhook.service';
@Module({
imports: [PrismaModule],
controllers: [BillingController],
providers: [BillingService],
controllers: [BillingController, StripeWebhookController],
providers: [BillingService, StripeWebhookService],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -4,9 +4,7 @@ import type { UsageListResponse, WalletResponse } from '@hexahost/contracts';
import { PrismaService } from '../prisma/prisma.service';
function isWhmcsBilling(): boolean {
return (process.env['BILLING_PROVIDER'] ?? 'internal') === 'whmcs';
}
import { isExternalBilling } from './billing-provider';
@Injectable()
export class BillingService {
@@ -99,7 +97,7 @@ export class BillingService {
}
async assertCanStartServer(userId: string): Promise<void> {
if (isWhmcsBilling()) {
if (isExternalBilling()) {
return;
}

View File

@@ -0,0 +1,33 @@
import {
BadRequestException,
Controller,
Headers,
Post,
Req,
} from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import type { FastifyRequest } from 'fastify';
import { Public } from '../../auth/decorators/current-user.decorator';
import { StripeWebhookService } from './stripe-webhook.service';
@Controller('webhooks/stripe')
@SkipThrottle()
export class StripeWebhookController {
constructor(private readonly stripe: StripeWebhookService) {}
@Public()
@Post()
handleWebhook(
@Req() request: FastifyRequest & { rawBody?: string },
@Headers('stripe-signature') signature?: string,
) {
const rawBody = request.rawBody;
if (!rawBody) {
throw new BadRequestException('Missing raw request body for Stripe webhook');
}
return this.stripe.handleWebhook(rawBody, signature);
}
}

View File

@@ -0,0 +1,126 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { isStripeBilling } from '../billing-provider';
@Injectable()
export class StripeWebhookService {
private readonly logger = new Logger(StripeWebhookService.name);
constructor(private readonly prisma: PrismaService) {}
handleWebhook(rawBody: string, signatureHeader?: string): { received: boolean; mode: string } {
if (!isStripeBilling()) {
this.logger.debug('Stripe webhook ignored — BILLING_PROVIDER is not stripe');
return { received: true, mode: 'ignored' };
}
const webhookSecret = process.env['STRIPE_WEBHOOK_SECRET'];
if (!webhookSecret) {
this.logger.warn('Stripe billing enabled but STRIPE_WEBHOOK_SECRET is not set');
return { received: true, mode: 'unconfigured' };
}
if (!signatureHeader || !this.verifySignature(rawBody, signatureHeader, webhookSecret)) {
this.logger.warn('Stripe webhook signature verification failed');
return { received: false, mode: 'invalid_signature' };
}
const event = JSON.parse(rawBody) as {
id?: string;
type?: string;
data?: { object?: Record<string, unknown> };
};
if (!event.id || !event.type) {
return { received: false, mode: 'invalid_payload' };
}
void this.processEventIdempotently(event.id, event.type, event.data?.object ?? {});
return { received: true, mode: 'processed' };
}
private verifySignature(
payload: string,
signatureHeader: string,
secret: string,
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => {
const [key, value] = part.split('=');
return [key, value];
}),
) as Record<string, string | undefined>;
const timestamp = parts['t'];
const signature = parts['v1'];
if (!timestamp || !signature) {
return false;
}
const signedPayload = `${timestamp}.${payload}`;
const expected = createHmac('sha256', secret).update(signedPayload).digest('hex');
const expectedBuffer = Buffer.from(expected, 'utf8');
const providedBuffer = Buffer.from(signature, 'utf8');
if (expectedBuffer.length !== providedBuffer.length) {
return false;
}
return timingSafeEqual(expectedBuffer, providedBuffer);
}
private async processEventIdempotently(
eventId: string,
eventType: string,
object: Record<string, unknown>,
): Promise<void> {
const idempotencyKey = `stripe:${eventId}`;
const existing = await this.prisma.integrationOperation.findUnique({
where: { idempotencyKey },
});
if (existing) {
return;
}
this.logger.log({ eventId, eventType }, 'Stripe webhook received');
await this.prisma.integrationOperation.create({
data: {
installationId: await this.resolveStripeInstallationId(),
idempotencyKey,
operation: `stripe:${eventType}`,
externalRef: eventId,
status: 'COMPLETED',
result: {
eventType,
objectId: object['id'] ?? null,
note: 'Stripe fallback handler — extend for direct billing without WHMCS',
},
},
});
}
private async resolveStripeInstallationId(): Promise<string> {
const installation = await this.prisma.whmcsInstallation.upsert({
where: { integrationId: 'stripe-fallback' },
create: {
integrationId: 'stripe-fallback',
name: 'Stripe fallback billing',
apiSecret: 'stripe-fallback-not-used-for-hmac',
isActive: true,
metadata: { source: 'stripe-webhook' },
},
update: {},
select: { id: true },
});
return installation.id;
}
}

View File

@@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import type { WhmcsDashboardStatsResponse } from '@hexahost/contracts';
import { getBillingProvider } from '../../billing/billing-provider';
import { PrismaService } from '../../prisma/prisma.service';
type InstallationRef = {
id: string;
integrationId: string;
};
@Injectable()
export class WhmcsDashboardService {
constructor(private readonly prisma: PrismaService) {}
async getStats(installation: InstallationRef): Promise<WhmcsDashboardStatsResponse> {
const [
linkedClients,
linkedServices,
pendingEvents,
deadLetterEvents,
openIssues,
exportedUsagePeriods,
syncCursor,
lastReconciledLink,
] = await Promise.all([
this.prisma.whmcsClientLink.count({
where: { installationId: installation.id },
}),
this.prisma.whmcsServiceLink.count({
where: { installationId: installation.id },
}),
this.prisma.integrationEvent.count({
where: { installationId: installation.id, status: 'PENDING' },
}),
this.prisma.integrationEvent.count({
where: { installationId: installation.id, status: 'DEAD_LETTER' },
}),
this.prisma.reconciliationIssue.count({
where: { installationId: installation.id, resolvedAt: null },
}),
this.prisma.whmcsUsageExport.count({
where: { installationId: installation.id, status: { in: ['EXPORTED', 'ADJUSTED'] } },
}),
this.prisma.integrationSyncCursor.findUnique({
where: { installationId: installation.id },
}),
this.prisma.whmcsServiceLink.findFirst({
where: {
installationId: installation.id,
lastReconciledAt: { not: null },
},
orderBy: { lastReconciledAt: 'desc' },
select: { lastReconciledAt: true },
}),
]);
const suspendedServices = await this.prisma.whmcsServiceLink.count({
where: { installationId: installation.id, status: 'SUSPENDED' },
});
const terminatedServices = await this.prisma.whmcsServiceLink.count({
where: { installationId: installation.id, status: 'TERMINATED' },
});
return {
integrationId: installation.integrationId,
billingProvider: getBillingProvider(),
moduleVersion: '1.0.0',
linkedClients,
linkedServices,
suspendedServices,
terminatedServices,
pendingEvents,
deadLetterEvents,
openReconciliationIssues: openIssues,
exportedUsagePeriods,
eventsCursor: syncCursor?.eventsCursor ?? null,
lastEventSyncAt: syncCursor?.updatedAt.toISOString() ?? null,
lastReconciliationAt: lastReconciledLink?.lastReconciledAt?.toISOString() ?? null,
};
}
}

View File

@@ -33,6 +33,7 @@ import {
type WhmcsAuthenticatedRequest,
} from './whmcs-integration.guard';
import { WhmcsIntegrationService } from './whmcs-integration.service';
import { WhmcsDashboardService } from './whmcs-dashboard.service';
import { WhmcsEventsService } from './whmcs-events.service';
import { WhmcsReconciliationService } from './whmcs-reconciliation.service';
import { WhmcsUsageService } from './whmcs-usage.service';
@@ -47,8 +48,14 @@ export class WhmcsIntegrationController {
private readonly reconciliation: WhmcsReconciliationService,
private readonly events: WhmcsEventsService,
private readonly usage: WhmcsUsageService,
private readonly dashboard: WhmcsDashboardService,
) {}
@Get('dashboard/stats')
dashboardStats(@Req() request: WhmcsAuthenticatedRequest) {
return this.dashboard.getStats(request.whmcsInstallation!);
}
@Get('health')
health(@Req() request: WhmcsAuthenticatedRequest) {
return this.whmcs.getHealth(request.whmcsInstallation!);

View File

@@ -8,6 +8,7 @@ import { AuthModule } from '../../auth/auth.module';
import { ServersModule } from '../../servers/servers.module';
import { WhmcsIntegrationController } from './whmcs-integration.controller';
import { WhmcsDashboardService } from './whmcs-dashboard.service';
import { WhmcsEventsService } from './whmcs-events.service';
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
import { WhmcsIntegrationGuard } from './whmcs-integration.guard';
@@ -25,6 +26,7 @@ export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
WhmcsReconciliationService,
WhmcsEventsService,
WhmcsUsageService,
WhmcsDashboardService,
WhmcsIntegrationGuard,
{
provide: INTEGRATIONS_REDIS,

View File

@@ -17,6 +17,7 @@ import { formatJoinAddress } from '@hexahost/dns';
import type { GameServer } from '@hexahost/database';
import { BillingService } from '../../billing/billing.service';
import { getBillingProvider } from '../../billing/billing-provider';
import { PrismaService } from '../../prisma/prisma.service';
import { ServersService } from '../../servers/servers.service';
@@ -39,7 +40,7 @@ export class WhmcsIntegrationService {
return {
status: 'ok' as const,
integrationId: installation.integrationId,
billingProvider: process.env['BILLING_PROVIDER'] ?? 'internal',
billingProvider: getBillingProvider(),
};
}

View File

@@ -17,7 +17,7 @@ describe('phase 9 api contract', () => {
join(process.cwd(), 'src', 'billing/billing.service.ts'),
'utf8',
);
assert.ok(billingSource.includes('isWhmcsBilling'));
assert.ok(billingSource.includes('isExternalBilling'));
const serversSource = readFileSync(
join(process.cwd(), 'src', 'servers/servers.service.ts'),

View File

@@ -0,0 +1,40 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('whmcs addon module contract', () => {
it('documents addon dashboard API and PHP module', () => {
const controller = readFileSync(
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-integration.controller.ts'),
'utf8',
);
assert.ok(controller.includes("'dashboard/stats'"));
const addonMain = readFileSync(
join(process.cwd(), '..', '..', 'integrations/whmcs/modules/addons/hexagamecloud/hexagamecloud.php'),
'utf8',
);
assert.ok(addonMain.includes('hexagamecloud_config'));
assert.ok(addonMain.includes('hexagamecloud_page_dashboard'));
assert.ok(addonMain.includes('HexaGameCloudEventPoller'));
});
});
describe('stripe fallback contract', () => {
it('documents optional stripe webhook and billing provider', () => {
const provider = readFileSync(
join(process.cwd(), 'src', 'billing/billing-provider.ts'),
'utf8',
);
assert.ok(provider.includes('isExternalBilling'));
assert.ok(provider.includes("'stripe'"));
const webhook = readFileSync(
join(process.cwd(), 'src', 'billing/stripe/stripe-webhook.service.ts'),
'utf8',
);
assert.ok(webhook.includes('isStripeBilling'));
assert.ok(webhook.includes('ignored'));
});
});