Implement WHMCS Addon Dashboard with API for stats and integrate billing provider logic
This commit is contained in:
23
apps/api/src/billing/billing-provider.ts
Normal file
23
apps/api/src/billing/billing-provider.ts
Normal 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';
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
33
apps/api/src/billing/stripe/stripe-webhook.controller.ts
Normal file
33
apps/api/src/billing/stripe/stripe-webhook.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
126
apps/api/src/billing/stripe/stripe-webhook.service.ts
Normal file
126
apps/api/src/billing/stripe/stripe-webhook.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
84
apps/api/src/integrations/whmcs/whmcs-dashboard.service.ts
Normal file
84
apps/api/src/integrations/whmcs/whmcs-dashboard.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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!);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
40
apps/api/test/whmcs-addon.test.js
Normal file
40
apps/api/test/whmcs-addon.test.js
Normal 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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user