Implement WHMCS Addon Dashboard with API for stats and integrate billing provider logic
This commit is contained in:
@@ -80,6 +80,7 @@ MODRINTH_USER_AGENT=HexaHostGameCloud/0.1.0 (contact@example.net)
|
||||
CURSEFORGE_API_KEY=
|
||||
|
||||
# --- Billing ---
|
||||
# internal = credit wallet (dev) | whmcs = production (recommended) | stripe = optional fallback
|
||||
BILLING_PROVIDER=internal
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,39 @@
|
||||
|
||||
Last updated: 2026-06-26
|
||||
|
||||
## Current phase: WHMCS Phase E + F — Sync & Usage Billing ✅
|
||||
## Current phase: WHMCS Addon Dashboard ✅
|
||||
|
||||
### WHMCS Addon module completed
|
||||
|
||||
| Area | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Addon PHP module | Done | Dashboard, events, reconciliation UI |
|
||||
| Module tables | Done | `mod_hexagamecloud_*` on activation |
|
||||
| Event poller | Done | Manual + daily cron hook |
|
||||
| Dashboard API | Done | `GET /integrations/whmcs/dashboard/stats` |
|
||||
| Cron hook | Done | `integrations/whmcs/hooks/hexagamecloud.php` |
|
||||
| Docs | Done | `docs/integrations/whmcs/addon-module.md` |
|
||||
|
||||
### Billing providers
|
||||
|
||||
| Provider | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| WHMCS | Done | **Production default** (`BILLING_PROVIDER=whmcs`) |
|
||||
| Internal | Done | Dev / free tier credits |
|
||||
| Stripe | Stub | Optional fallback webhook — inactive unless `BILLING_PROVIDER=stripe` |
|
||||
|
||||
See `docs/billing/providers.md`.
|
||||
|
||||
### Abnahmekriterium Addon
|
||||
|
||||
- [x] WHMCS admin dashboard shows API health and sync stats
|
||||
- [x] Event poll + ack from addon UI and cron hook
|
||||
- [x] Reconciliation dry-run from addon UI
|
||||
- [ ] Manual E2E: activate addon on WHMCS host
|
||||
|
||||
---
|
||||
|
||||
## WHMCS Phase E + F — Sync & Usage Billing ✅
|
||||
|
||||
### WHMCS Phase E completed
|
||||
|
||||
@@ -234,4 +266,4 @@ Server entry fields:
|
||||
|
||||
### Next phase: Post-MVP hardening
|
||||
|
||||
- Stripe webhooks, penetration test, WHMCS addon dashboard UI
|
||||
- Penetration test, product mapping UI in addon, mTLS for integration API
|
||||
|
||||
43
docs/billing/providers.md
Normal file
43
docs/billing/providers.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Billing providers
|
||||
|
||||
GameCloud supports three billing modes via `BILLING_PROVIDER`:
|
||||
|
||||
| Provider | Use case | Credit wallet |
|
||||
|----------|----------|---------------|
|
||||
| `internal` | Dev / free tier | Required for start |
|
||||
| `whmcs` | **Production default** | Bypassed — WHMCS bills customers |
|
||||
| `stripe` | Optional fallback without WHMCS | Bypassed — extend webhook handler |
|
||||
|
||||
## WHMCS (recommended)
|
||||
|
||||
```env
|
||||
BILLING_PROVIDER=whmcs
|
||||
WHMCS_INTEGRATION_ID=your-integration-id
|
||||
WHMCS_API_SECRET=your-secret
|
||||
```
|
||||
|
||||
Commercial lifecycle (orders, invoices, suspend, usage metrics) runs entirely in WHMCS. GameCloud enforces `billingSuspendedAt` from WHMCS API calls.
|
||||
|
||||
## Internal (development)
|
||||
|
||||
```env
|
||||
BILLING_PROVIDER=internal
|
||||
```
|
||||
|
||||
Uses the credit wallet and usage metering from Phase 7. Suitable for local development and standalone free tier.
|
||||
|
||||
## Stripe (optional fallback)
|
||||
|
||||
```env
|
||||
BILLING_PROVIDER=stripe
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
```
|
||||
|
||||
Webhook endpoint: `POST /api/v1/webhooks/stripe`
|
||||
|
||||
When `BILLING_PROVIDER` is not `stripe`, webhooks return `{ received: true, mode: 'ignored' }` without side effects.
|
||||
|
||||
The current implementation verifies signatures and logs events idempotently. Extend `StripeWebhookService` to map Stripe customers to GameCloud users if you deploy without WHMCS.
|
||||
|
||||
**Do not enable Stripe and WHMCS billing simultaneously** — choose one commercial system of record.
|
||||
29
docs/integrations/whmcs/addon-module.md
Normal file
29
docs/integrations/whmcs/addon-module.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# WHMCS addon module
|
||||
|
||||
The addon provides global GameCloud administration inside WHMCS. **WHMCS remains the commercial system of record**; this module does not process payments.
|
||||
|
||||
## Install
|
||||
|
||||
1. Copy `integrations/whmcs/modules/addons/hexagamecloud` to `/modules/addons/hexagamecloud`
|
||||
2. **Setup → Addon Modules** → activate **HexaHost GameCloud**
|
||||
3. Configure:
|
||||
- **GameCloud API URL** — e.g. `https://api.example.net`
|
||||
- **Integration ID** — matches `WHMCS_INTEGRATION_ID` on GameCloud
|
||||
- **API Secret** — matches `WHMCS_API_SECRET`
|
||||
4. Copy `integrations/whmcs/hooks/hexagamecloud.php` to WHMCS `includes/hooks/hexagamecloud.php`
|
||||
|
||||
## Pages
|
||||
|
||||
| Tab | Purpose |
|
||||
|-----|---------|
|
||||
| Dashboard | Health, counts, last sync |
|
||||
| Events | Poll, view, ack, dead-letter |
|
||||
| Reconciliation | Dry-run / live reconcile |
|
||||
|
||||
## Cron
|
||||
|
||||
The daily hook polls pending GameCloud events and optionally runs a dry reconciliation when **Auto-reconcile on daily cron** is enabled.
|
||||
|
||||
## Billing
|
||||
|
||||
Set `BILLING_PROVIDER=whmcs` on GameCloud in production. Stripe (`BILLING_PROVIDER=stripe`) is an optional fallback for standalone deployments without WHMCS — see [billing providers](../../billing/providers.md).
|
||||
@@ -60,8 +60,10 @@ Handles global configuration:
|
||||
|
||||
**Phase F (Usage Billing)** — implemented. See [docs/integrations/whmcs/usage-billing.md](../../docs/integrations/whmcs/usage-billing.md).
|
||||
|
||||
**Addon module (Dashboard)** — implemented. See [docs/integrations/whmcs/addon-module.md](../../docs/integrations/whmcs/addon-module.md).
|
||||
|
||||
- Server module: `integrations/whmcs/modules/servers/hexagamecloud/`
|
||||
- Addon module: planned for global mappings and reconciliation UI
|
||||
- Addon module: `integrations/whmcs/modules/addons/hexagamecloud/`
|
||||
|
||||
## Documentation (planned)
|
||||
|
||||
|
||||
42
integrations/whmcs/hooks/hexagamecloud.php
Normal file
42
integrations/whmcs/hooks/hexagamecloud.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
if (!defined('WHMCS')) {
|
||||
die('This file cannot be accessed directly');
|
||||
}
|
||||
|
||||
use WHMCS\Database\Capsule;
|
||||
|
||||
/**
|
||||
* Copy this file to WHMCS includes/hooks/hexagamecloud.php
|
||||
*/
|
||||
add_hook('DailyCronJob', 1, function () {
|
||||
$rows = Capsule::table('tbladdonmodules')
|
||||
->where('module', 'hexagamecloud')
|
||||
->pluck('value', 'setting');
|
||||
|
||||
$apiUrl = trim((string) ($rows['api_url'] ?? ''));
|
||||
$integrationId = trim((string) ($rows['integration_id'] ?? ''));
|
||||
$apiSecret = trim((string) ($rows['api_secret'] ?? ''));
|
||||
|
||||
if ($apiUrl === '' || $integrationId === '' || $apiSecret === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
require_once ROOTDIR . '/modules/addons/hexagamecloud/lib/ApiClient.php';
|
||||
require_once ROOTDIR . '/modules/addons/hexagamecloud/lib/ModuleStorage.php';
|
||||
require_once ROOTDIR . '/modules/addons/hexagamecloud/lib/EventPoller.php';
|
||||
|
||||
try {
|
||||
$client = new HexaGameCloudAddonApiClient($apiUrl, $integrationId, $apiSecret);
|
||||
$storage = new HexaGameCloudModuleStorage();
|
||||
$limit = (int) ($rows['event_poll_limit'] ?? 50);
|
||||
$poller = new HexaGameCloudEventPoller($client, $storage, max(1, min($limit, 100)));
|
||||
$poller->pollAndAcknowledge();
|
||||
|
||||
if (!empty($rows['auto_reconcile'])) {
|
||||
$client->reconcileAll(['dryRun' => true]);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
logActivity('HexaGameCloud cron poll failed: ' . $exception->getMessage());
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,36 @@
|
||||
# Addon module placeholder
|
||||
# WHMCS addon module
|
||||
|
||||
Implementation planned in WHMCS Phase A.
|
||||
Install path: `/modules/addons/hexagamecloud/`
|
||||
|
||||
Target install path: `/modules/addons/hexagamecloud/`
|
||||
## Features
|
||||
|
||||
Handles global API settings, product mappings, reconciliation, and event polling.
|
||||
- **Dashboard** — API health, linked services, pending events, reconciliation summary
|
||||
- **Events** — Poll GameCloud cursor events, local cache, manual ack / dead-letter
|
||||
- **Reconciliation** — Dry-run and live runs with optional auto-repair
|
||||
- **Daily cron** — Copy `integrations/whmcs/hooks/hexagamecloud.php` to WHMCS `includes/hooks/`
|
||||
|
||||
## Setup
|
||||
|
||||
1. Activate **Addon Modules → HexaHost GameCloud**
|
||||
2. Configure API URL, Integration ID, API Secret (same as GameCloud `WHMCS_*` seed values)
|
||||
3. Copy the hook file for automated polling
|
||||
4. Open **Addons → HexaHost GameCloud** for the dashboard
|
||||
|
||||
## Tables
|
||||
|
||||
Created on activation (`sql/install.sql`):
|
||||
|
||||
- `mod_hexagamecloud_installations`
|
||||
- `mod_hexagamecloud_sync_cursors`
|
||||
- `mod_hexagamecloud_events`
|
||||
- `mod_hexagamecloud_operations`
|
||||
- `mod_hexagamecloud_reconciliation`
|
||||
|
||||
## API used
|
||||
|
||||
- `GET /integrations/whmcs/dashboard/stats`
|
||||
- `GET /integrations/whmcs/accounts`
|
||||
- `GET /integrations/whmcs/events` + `POST .../events/ack`
|
||||
- `POST /integrations/whmcs/reconcile`
|
||||
|
||||
See [docs/integrations/whmcs/addon-module.md](../../../../docs/integrations/whmcs/addon-module.md).
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use WHMCS\Database\Capsule;
|
||||
|
||||
if (!defined('WHMCS')) {
|
||||
die('This file cannot be accessed directly');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/lib/ApiClient.php';
|
||||
require_once __DIR__ . '/lib/ModuleStorage.php';
|
||||
require_once __DIR__ . '/lib/EventPoller.php';
|
||||
|
||||
const HEXAGAMECLOUD_ADDON_VERSION = '1.0.0';
|
||||
|
||||
function hexagamecloud_config(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'HexaHost GameCloud',
|
||||
'description' => 'Global GameCloud integration — dashboard, reconciliation, and event polling.',
|
||||
'version' => HEXAGAMECLOUD_ADDON_VERSION,
|
||||
'author' => 'HexaHost',
|
||||
'language' => 'english',
|
||||
'fields' => [
|
||||
'api_url' => [
|
||||
'FriendlyName' => 'GameCloud API URL',
|
||||
'Type' => 'text',
|
||||
'Size' => '128',
|
||||
'Default' => 'https://api.example.net',
|
||||
'Description' => 'Base URL of the GameCloud API (no trailing slash)',
|
||||
],
|
||||
'integration_id' => [
|
||||
'FriendlyName' => 'Integration ID',
|
||||
'Type' => 'text',
|
||||
'Size' => '64',
|
||||
'Description' => 'Matches WHMCS_INTEGRATION_ID on GameCloud',
|
||||
],
|
||||
'api_secret' => [
|
||||
'FriendlyName' => 'API Secret',
|
||||
'Type' => 'password',
|
||||
'Size' => '128',
|
||||
'Description' => 'HMAC signing secret — stored encrypted by WHMCS',
|
||||
],
|
||||
'sso_origin' => [
|
||||
'FriendlyName' => 'SSO Panel Origin',
|
||||
'Type' => 'text',
|
||||
'Size' => '128',
|
||||
'Default' => 'https://panel.example.net',
|
||||
'Description' => 'Customer panel URL for SSO redirects',
|
||||
],
|
||||
'event_poll_limit' => [
|
||||
'FriendlyName' => 'Event poll batch size',
|
||||
'Type' => 'text',
|
||||
'Size' => '4',
|
||||
'Default' => '50',
|
||||
],
|
||||
'auto_reconcile' => [
|
||||
'FriendlyName' => 'Auto-reconcile on daily cron',
|
||||
'Type' => 'yesno',
|
||||
'Description' => 'Run dry reconciliation during daily event poll',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function hexagamecloud_activate(): array
|
||||
{
|
||||
try {
|
||||
$sql = file_get_contents(__DIR__ . '/sql/install.sql');
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException('Unable to read install.sql');
|
||||
}
|
||||
|
||||
foreach (array_filter(array_map('trim', explode(';', $sql))) as $statement) {
|
||||
if ($statement !== '') {
|
||||
Capsule::connection()->statement($statement);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'description' => 'HexaHost GameCloud addon activated. Copy hooks/hexagamecloud.php to includes/hooks/ for cron polling.',
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'description' => 'Activation failed: ' . $exception->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function hexagamecloud_deactivate(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'success',
|
||||
'description' => 'Addon deactivated. Module tables were kept for audit history.',
|
||||
];
|
||||
}
|
||||
|
||||
function hexagamecloud_output(array $vars): void
|
||||
{
|
||||
$action = $_GET['action'] ?? 'dashboard';
|
||||
$modulelink = $vars['modulelink'];
|
||||
$storage = new HexaGameCloudModuleStorage();
|
||||
|
||||
try {
|
||||
$client = hexagamecloud_create_client($vars);
|
||||
} catch (Throwable $exception) {
|
||||
hexagamecloud_render_error($vars, $exception->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
case 'events':
|
||||
hexagamecloud_page_events($vars, $client, $storage, $modulelink);
|
||||
break;
|
||||
case 'reconciliation':
|
||||
hexagamecloud_page_reconciliation($vars, $client, $storage, $modulelink);
|
||||
break;
|
||||
case 'poll':
|
||||
hexagamecloud_action_poll($vars, $client, $storage, $modulelink);
|
||||
break;
|
||||
case 'reconcile':
|
||||
hexagamecloud_action_reconcile($vars, $client, $storage, $modulelink);
|
||||
break;
|
||||
case 'ack':
|
||||
hexagamecloud_action_ack($vars, $client, $storage, $modulelink);
|
||||
break;
|
||||
default:
|
||||
hexagamecloud_page_dashboard($vars, $client, $storage, $modulelink);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function hexagamecloud_create_client(array $vars): HexaGameCloudAddonApiClient
|
||||
{
|
||||
$apiUrl = trim((string) ($vars['api_url'] ?? ''));
|
||||
$integrationId = trim((string) ($vars['integration_id'] ?? ''));
|
||||
$apiSecret = trim((string) ($vars['api_secret'] ?? ''));
|
||||
|
||||
if ($apiUrl === '' || $integrationId === '' || $apiSecret === '') {
|
||||
throw new RuntimeException('Configure API URL, Integration ID and API Secret in addon settings.');
|
||||
}
|
||||
|
||||
return new HexaGameCloudAddonApiClient($apiUrl, $integrationId, $apiSecret);
|
||||
}
|
||||
|
||||
function hexagamecloud_page_dashboard(
|
||||
array $vars,
|
||||
HexaGameCloudAddonApiClient $client,
|
||||
HexaGameCloudModuleStorage $storage,
|
||||
string $modulelink
|
||||
): void {
|
||||
$health = $client->health();
|
||||
$stats = $client->dashboardStats();
|
||||
$accounts = $client->listAccounts();
|
||||
$unlinked = hexagamecloud_count_unlinked_whmcs_services($accounts['accounts'] ?? []);
|
||||
|
||||
$orphans = 0;
|
||||
foreach ($accounts['accounts'] ?? [] as $account) {
|
||||
if (($account['linkStatus'] ?? '') === 'TERMINATED' && ($account['gameCloudStatus'] ?? '') !== 'DELETED') {
|
||||
$orphans++;
|
||||
}
|
||||
}
|
||||
|
||||
$storage->recordOperation('dashboard:view', 'success', [
|
||||
'integrationId' => $stats['integrationId'] ?? null,
|
||||
]);
|
||||
|
||||
echo hexagamecloud_render_template('dashboard', [
|
||||
'modulelink' => $modulelink,
|
||||
'version' => HEXAGAMECLOUD_ADDON_VERSION,
|
||||
'health' => $health,
|
||||
'stats' => $stats,
|
||||
'linkedServices' => count($accounts['accounts'] ?? []),
|
||||
'unlinkedWhmcsServices' => $unlinked,
|
||||
'driftCount' => $orphans,
|
||||
'lastError' => $storage->getLastError(),
|
||||
]);
|
||||
}
|
||||
|
||||
function hexagamecloud_page_events(
|
||||
array $vars,
|
||||
HexaGameCloudAddonApiClient $client,
|
||||
HexaGameCloudModuleStorage $storage,
|
||||
string $modulelink
|
||||
): void {
|
||||
$localEvents = $storage->listLocalEvents(100);
|
||||
$pendingCount = $storage->countLocalEventsByStatus('pending');
|
||||
|
||||
echo hexagamecloud_render_template('events', [
|
||||
'modulelink' => $modulelink,
|
||||
'localEvents' => $localEvents,
|
||||
'pendingCount' => $pendingCount,
|
||||
'deadLetterCount' => $storage->countLocalEventsByStatus('dead_letter'),
|
||||
]);
|
||||
}
|
||||
|
||||
function hexagamecloud_page_reconciliation(
|
||||
array $vars,
|
||||
HexaGameCloudAddonApiClient $client,
|
||||
HexaGameCloudModuleStorage $storage,
|
||||
string $modulelink
|
||||
): void {
|
||||
$runs = $storage->listReconciliationRuns(20);
|
||||
$dryRun = isset($_GET['dry']) ? true : false;
|
||||
$liveResult = null;
|
||||
|
||||
if (isset($_GET['run'])) {
|
||||
try {
|
||||
$liveResult = $client->reconcileAll([
|
||||
'dryRun' => $dryRun,
|
||||
'autoRepair' => !$dryRun && !empty($_GET['repair']),
|
||||
]);
|
||||
$storage->saveReconciliationRun($liveResult, $dryRun ? 'dry_run' : 'live');
|
||||
} catch (Throwable $exception) {
|
||||
$storage->setLastError($exception->getMessage());
|
||||
$liveResult = ['error' => $exception->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
echo hexagamecloud_render_template('reconciliation', [
|
||||
'modulelink' => $modulelink,
|
||||
'runs' => $runs,
|
||||
'liveResult' => $liveResult,
|
||||
'dryRun' => $dryRun,
|
||||
]);
|
||||
}
|
||||
|
||||
function hexagamecloud_action_poll(
|
||||
array $vars,
|
||||
HexaGameCloudAddonApiClient $client,
|
||||
HexaGameCloudModuleStorage $storage,
|
||||
string $modulelink
|
||||
): void {
|
||||
$limit = (int) ($vars['event_poll_limit'] ?? 50);
|
||||
$poller = new HexaGameCloudEventPoller($client, $storage, max(1, min($limit, 100)));
|
||||
|
||||
try {
|
||||
$result = $poller->pollAndAcknowledge();
|
||||
$storage->recordOperation('events:poll', 'success', $result);
|
||||
} catch (Throwable $exception) {
|
||||
$storage->setLastError($exception->getMessage());
|
||||
$storage->recordOperation('events:poll', 'error', ['message' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
header('Location: ' . $modulelink . '&action=events');
|
||||
exit;
|
||||
}
|
||||
|
||||
function hexagamecloud_action_reconcile(
|
||||
array $vars,
|
||||
HexaGameCloudAddonApiClient $client,
|
||||
HexaGameCloudModuleStorage $storage,
|
||||
string $modulelink
|
||||
): void {
|
||||
header('Location: ' . $modulelink . '&action=reconciliation&run=1&dry=1');
|
||||
exit;
|
||||
}
|
||||
|
||||
function hexagamecloud_action_ack(
|
||||
array $vars,
|
||||
HexaGameCloudAddonApiClient $client,
|
||||
HexaGameCloudModuleStorage $storage,
|
||||
string $modulelink
|
||||
): void {
|
||||
$eventId = (int) ($_POST['local_event_id'] ?? 0);
|
||||
$deadLetter = !empty($_POST['dead_letter']);
|
||||
$local = $storage->getLocalEvent($eventId);
|
||||
|
||||
if ($local && !empty($local['remote_event_id'])) {
|
||||
try {
|
||||
$client->acknowledgeEvents([$local['remote_event_id']], $deadLetter, 'Manual ack from WHMCS addon');
|
||||
$storage->updateLocalEventStatus($eventId, $deadLetter ? 'dead_letter' : 'acknowledged');
|
||||
} catch (Throwable $exception) {
|
||||
$storage->setLastError($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
header('Location: ' . $modulelink . '&action=events');
|
||||
exit;
|
||||
}
|
||||
|
||||
function hexagamecloud_count_unlinked_whmcs_services(array $linkedAccounts): int
|
||||
{
|
||||
$linkedIds = [];
|
||||
foreach ($linkedAccounts as $account) {
|
||||
if (!empty($account['externalServiceId'])) {
|
||||
$linkedIds[(string) $account['externalServiceId']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$hosting = Capsule::table('tblhosting')
|
||||
->join('tblproducts', 'tblproducts.id', '=', 'tblhosting.packageid')
|
||||
->where('tblproducts.servertype', 'hexagamecloud')
|
||||
->whereIn('tblhosting.domainstatus', ['Active', 'Suspended', 'Pending'])
|
||||
->select('tblhosting.id')
|
||||
->get();
|
||||
|
||||
$unlinked = 0;
|
||||
foreach ($hosting as $row) {
|
||||
if (!isset($linkedIds[(string) $row->id])) {
|
||||
$unlinked++;
|
||||
}
|
||||
}
|
||||
|
||||
return $unlinked;
|
||||
}
|
||||
|
||||
function hexagamecloud_render_template(string $name, array $data): string
|
||||
{
|
||||
$path = __DIR__ . '/templates/' . $name . '.tpl';
|
||||
if (!is_readable($path)) {
|
||||
return '<div class="alert alert-danger">Template missing: ' . htmlspecialchars($name) . '</div>';
|
||||
}
|
||||
|
||||
$html = file_get_contents($path);
|
||||
if ($html === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
$placeholder = '{{' . $key . '}}';
|
||||
if (is_scalar($value) || $value === null) {
|
||||
$html = str_replace($placeholder, htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'), $html);
|
||||
}
|
||||
}
|
||||
|
||||
return hexagamecloud_expand_blocks($html, $data);
|
||||
}
|
||||
|
||||
function hexagamecloud_expand_blocks(string $html, array $data): string
|
||||
{
|
||||
if (!empty($data['stats']) && is_array($data['stats'])) {
|
||||
foreach ($data['stats'] as $key => $value) {
|
||||
$html = str_replace('{{stats.' . $key . '}}', htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'), $html);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($data['health']) && is_array($data['health'])) {
|
||||
foreach ($data['health'] as $key => $value) {
|
||||
$html = str_replace('{{health.' . $key . '}}', htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'), $html);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($data['localEvents']) && is_array($data['localEvents'])) {
|
||||
$rows = '';
|
||||
foreach ($data['localEvents'] as $event) {
|
||||
$rows .= '<tr>';
|
||||
$rows .= '<td>' . (int) $event['id'] . '</td>';
|
||||
$rows .= '<td>' . htmlspecialchars((string) $event['event_type'], ENT_QUOTES, 'UTF-8') . '</td>';
|
||||
$rows .= '<td>' . htmlspecialchars((string) $event['status'], ENT_QUOTES, 'UTF-8') . '</td>';
|
||||
$rows .= '<td><code>' . htmlspecialchars((string) $event['payload'], ENT_QUOTES, 'UTF-8') . '</code></td>';
|
||||
$rows .= '<td>' . htmlspecialchars((string) $event['created_at'], ENT_QUOTES, 'UTF-8') . '</td>';
|
||||
$rows .= '<td><form method="post" action="' . htmlspecialchars((string) $data['modulelink'], ENT_QUOTES, 'UTF-8') . '&action=ack">';
|
||||
$rows .= '<input type="hidden" name="local_event_id" value="' . (int) $event['id'] . '">';
|
||||
$rows .= '<button type="submit" class="btn btn-default btn-xs">Ack</button> ';
|
||||
$rows .= '<button type="submit" name="dead_letter" value="1" class="btn btn-danger btn-xs">Dead letter</button>';
|
||||
$rows .= '</form></td>';
|
||||
$rows .= '</tr>';
|
||||
}
|
||||
$html = str_replace('{{events.rows}}', $rows, $html);
|
||||
}
|
||||
|
||||
if (!empty($data['runs']) && is_array($data['runs'])) {
|
||||
$rows = '';
|
||||
foreach ($data['runs'] as $run) {
|
||||
$rows .= '<tr>';
|
||||
$rows .= '<td>' . htmlspecialchars((string) $run['run_id'], ENT_QUOTES, 'UTF-8') . '</td>';
|
||||
$rows .= '<td>' . htmlspecialchars((string) $run['mode'], ENT_QUOTES, 'UTF-8') . '</td>';
|
||||
$rows .= '<td>' . (int) $run['issue_count'] . '</td>';
|
||||
$rows .= '<td>' . htmlspecialchars((string) $run['created_at'], ENT_QUOTES, 'UTF-8') . '</td>';
|
||||
$rows .= '</tr>';
|
||||
}
|
||||
$html = str_replace('{{reconciliation.rows}}', $rows, $html);
|
||||
}
|
||||
|
||||
if (!empty($data['liveResult']) && is_array($data['liveResult'])) {
|
||||
$html = str_replace(
|
||||
'{{reconciliation.result}}',
|
||||
'<pre>' . htmlspecialchars(json_encode($data['liveResult'], JSON_PRETTY_PRINT), ENT_QUOTES, 'UTF-8') . '</pre>',
|
||||
$html
|
||||
);
|
||||
} else {
|
||||
$html = str_replace('{{reconciliation.result}}', '', $html);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
function hexagamecloud_render_error(array $vars, string $message): void
|
||||
{
|
||||
echo '<div class="alert alert-danger"><strong>GameCloud addon error:</strong> ' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '</div>';
|
||||
echo '<p>Configure the addon under <em>Setup → Addon Modules → HexaHost GameCloud</em>.</p>';
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class HexaGameCloudAddonApiClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $baseUrl,
|
||||
private readonly string $integrationId,
|
||||
private readonly string $apiSecret,
|
||||
) {
|
||||
if ($this->baseUrl === '' || $this->integrationId === '' || $this->apiSecret === '') {
|
||||
throw new RuntimeException('API URL, integration ID and secret are required');
|
||||
}
|
||||
}
|
||||
|
||||
public function health(): array
|
||||
{
|
||||
return $this->request('GET', '/api/v1/integrations/whmcs/health');
|
||||
}
|
||||
|
||||
public function dashboardStats(): array
|
||||
{
|
||||
return $this->request('GET', '/api/v1/integrations/whmcs/dashboard/stats');
|
||||
}
|
||||
|
||||
public function listAccounts(): array
|
||||
{
|
||||
return $this->request('GET', '/api/v1/integrations/whmcs/accounts');
|
||||
}
|
||||
|
||||
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 reconcileAll(array $payload = []): array
|
||||
{
|
||||
return $this->request(
|
||||
'POST',
|
||||
'/api/v1/integrations/whmcs/reconcile',
|
||||
$payload,
|
||||
'reconcile:all:' . date('Y-m-d-His')
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
$timestamp = (string) (int) (microtime(true) * 1000);
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$idempotencyKey = $idempotencySuffix ?: $method . ':' . $path . ':' . $timestamp;
|
||||
|
||||
$signatureBase = implode("\n", [
|
||||
strtoupper($method),
|
||||
$path,
|
||||
$canonicalQuery,
|
||||
hash('sha256', $bodyJson),
|
||||
$timestamp,
|
||||
$nonce,
|
||||
$idempotencyKey,
|
||||
]);
|
||||
|
||||
$signature = hash_hmac('sha256', $signatureBase, $this->apiSecret);
|
||||
$requestUrl = rtrim($this->baseUrl, '/') . $path . ($canonicalQuery !== '' ? ('?' . $canonicalQuery) : '');
|
||||
|
||||
$ch = curl_init($requestUrl);
|
||||
if ($ch === false) {
|
||||
throw new RuntimeException('Unable to initialize HTTP client');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'X-HGC-Integration-ID: ' . $this->integrationId,
|
||||
'X-HGC-Timestamp: ' . $timestamp,
|
||||
'X-HGC-Nonce: ' . $nonce,
|
||||
'X-HGC-Idempotency-Key: ' . $idempotencyKey,
|
||||
'X-HGC-Signature: ' . $signature,
|
||||
],
|
||||
CURLOPT_POSTFIELDS => $bodyJson,
|
||||
]);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($responseBody === false) {
|
||||
throw new RuntimeException('GameCloud request failed: ' . $curlError);
|
||||
}
|
||||
|
||||
$decoded = json_decode($responseBody, true);
|
||||
if ($statusCode >= 400) {
|
||||
$message = is_array($decoded)
|
||||
? ($decoded['detail'] ?? $decoded['title'] ?? $responseBody)
|
||||
: $responseBody;
|
||||
throw new RuntimeException('GameCloud API error (' . $statusCode . '): ' . $message);
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class HexaGameCloudEventPoller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HexaGameCloudAddonApiClient $client,
|
||||
private readonly HexaGameCloudModuleStorage $storage,
|
||||
private readonly int $limit = 50,
|
||||
) {
|
||||
}
|
||||
|
||||
public function pollAndAcknowledge(): array
|
||||
{
|
||||
$cursor = $this->storage->getSyncCursor();
|
||||
$response = $this->client->listEvents($cursor, $this->limit);
|
||||
$events = $response['events'] ?? [];
|
||||
$ackIds = [];
|
||||
$stored = 0;
|
||||
|
||||
foreach ($events as $event) {
|
||||
$remoteId = (string) ($event['id'] ?? '');
|
||||
$eventType = (string) ($event['eventType'] ?? 'unknown');
|
||||
$payload = is_array($event['payload'] ?? null) ? $event['payload'] : [];
|
||||
|
||||
if ($remoteId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->storage->storeRemoteEvent($remoteId, $eventType, $payload);
|
||||
$stored++;
|
||||
|
||||
if ($this->shouldAutoAck($eventType)) {
|
||||
$ackIds[] = $remoteId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($ackIds !== []) {
|
||||
$this->client->acknowledgeEvents($ackIds);
|
||||
}
|
||||
|
||||
if (!empty($response['nextCursor'])) {
|
||||
$this->storage->updateSyncCursor((string) $response['nextCursor']);
|
||||
} elseif ($events !== []) {
|
||||
$last = $events[count($events) - 1];
|
||||
if (!empty($last['id'])) {
|
||||
$this->storage->updateSyncCursor((string) $last['id']);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'fetched' => count($events),
|
||||
'stored' => $stored,
|
||||
'acknowledged' => count($ackIds),
|
||||
'nextCursor' => $response['nextCursor'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldAutoAck(string $eventType): bool
|
||||
{
|
||||
return in_array($eventType, ['usage_period_ready', 'usage_period_closed'], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use WHMCS\Database\Capsule;
|
||||
|
||||
final class HexaGameCloudModuleStorage
|
||||
{
|
||||
public function recordOperation(string $operation, string $status, array $metadata = []): void
|
||||
{
|
||||
Capsule::table('mod_hexagamecloud_operations')->insert([
|
||||
'operation' => $operation,
|
||||
'status' => $status,
|
||||
'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR),
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function setLastError(string $message): void
|
||||
{
|
||||
Capsule::table('mod_hexagamecloud_installations')->updateOrInsert(
|
||||
['id' => 1],
|
||||
[
|
||||
'last_error' => $message,
|
||||
'updated_at' => gmdate('Y-m-d H:i:s'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getLastError(): ?string
|
||||
{
|
||||
$row = Capsule::table('mod_hexagamecloud_installations')->where('id', 1)->first();
|
||||
return $row?->last_error;
|
||||
}
|
||||
|
||||
public function updateSyncCursor(?string $cursor): void
|
||||
{
|
||||
Capsule::table('mod_hexagamecloud_sync_cursors')->updateOrInsert(
|
||||
['id' => 1],
|
||||
[
|
||||
'events_cursor' => $cursor,
|
||||
'last_poll_at' => gmdate('Y-m-d H:i:s'),
|
||||
'updated_at' => gmdate('Y-m-d H:i:s'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getSyncCursor(): ?string
|
||||
{
|
||||
$row = Capsule::table('mod_hexagamecloud_sync_cursors')->where('id', 1)->first();
|
||||
return $row?->events_cursor;
|
||||
}
|
||||
|
||||
public function storeRemoteEvent(string $remoteId, string $eventType, array $payload, string $status = 'pending'): void
|
||||
{
|
||||
$existing = Capsule::table('mod_hexagamecloud_events')
|
||||
->where('remote_event_id', $remoteId)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
Capsule::table('mod_hexagamecloud_events')->insert([
|
||||
'remote_event_id' => $remoteId,
|
||||
'event_type' => $eventType,
|
||||
'payload' => json_encode($payload, JSON_THROW_ON_ERROR),
|
||||
'status' => $status,
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function listLocalEvents(int $limit = 50): array
|
||||
{
|
||||
return Capsule::table('mod_hexagamecloud_events')
|
||||
->orderBy('id', 'desc')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(static fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
public function countLocalEventsByStatus(string $status): int
|
||||
{
|
||||
return (int) Capsule::table('mod_hexagamecloud_events')->where('status', $status)->count();
|
||||
}
|
||||
|
||||
public function getLocalEvent(int $id): ?array
|
||||
{
|
||||
$row = Capsule::table('mod_hexagamecloud_events')->where('id', $id)->first();
|
||||
return $row ? (array) $row : null;
|
||||
}
|
||||
|
||||
public function updateLocalEventStatus(int $id, string $status): void
|
||||
{
|
||||
Capsule::table('mod_hexagamecloud_events')->where('id', $id)->update([
|
||||
'status' => $status,
|
||||
'processed_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveReconciliationRun(array $result, string $mode): void
|
||||
{
|
||||
$issues = $result['issues'] ?? [];
|
||||
Capsule::table('mod_hexagamecloud_reconciliation')->insert([
|
||||
'run_id' => (string) ($result['runId'] ?? ''),
|
||||
'mode' => $mode,
|
||||
'issue_count' => is_array($issues) ? count($issues) : 0,
|
||||
'payload' => json_encode($result, JSON_THROW_ON_ERROR),
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function listReconciliationRuns(int $limit = 20): array
|
||||
{
|
||||
return Capsule::table('mod_hexagamecloud_reconciliation')
|
||||
->orderBy('id', 'desc')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(static fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
CREATE TABLE IF NOT EXISTS `mod_hexagamecloud_installations` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`last_error` text NULL,
|
||||
`updated_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `mod_hexagamecloud_sync_cursors` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`events_cursor` varchar(64) NULL,
|
||||
`last_poll_at` datetime NULL,
|
||||
`updated_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `mod_hexagamecloud_events` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`remote_event_id` varchar(64) NOT NULL,
|
||||
`event_type` varchar(64) NOT NULL,
|
||||
`payload` mediumtext NOT NULL,
|
||||
`status` varchar(32) NOT NULL DEFAULT 'pending',
|
||||
`processed_at` datetime NULL,
|
||||
`created_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `remote_event_id` (`remote_event_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `mod_hexagamecloud_operations` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`operation` varchar(64) NOT NULL,
|
||||
`status` varchar(32) NOT NULL,
|
||||
`metadata` mediumtext NULL,
|
||||
`created_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `mod_hexagamecloud_reconciliation` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`run_id` varchar(64) NOT NULL,
|
||||
`mode` varchar(32) NOT NULL,
|
||||
`issue_count` int unsigned NOT NULL DEFAULT 0,
|
||||
`payload` mediumtext NULL,
|
||||
`created_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO `mod_hexagamecloud_installations` (`id`, `updated_at`) VALUES (1, UTC_TIMESTAMP());
|
||||
|
||||
INSERT IGNORE INTO `mod_hexagamecloud_sync_cursors` (`id`, `updated_at`) VALUES (1, UTC_TIMESTAMP());
|
||||
@@ -0,0 +1,55 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">HexaHost GameCloud — Dashboard</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>
|
||||
<a href="{{modulelink}}" class="btn btn-default">Dashboard</a>
|
||||
<a href="{{modulelink}}&action=events" class="btn btn-default">Events</a>
|
||||
<a href="{{modulelink}}&action=reconciliation" class="btn btn-default">Reconciliation</a>
|
||||
<a href="{{modulelink}}&action=poll" class="btn btn-primary">Poll events now</a>
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="well text-center">
|
||||
<h4>{{health.status}}</h4>
|
||||
<small>API status</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="well text-center">
|
||||
<h4>{{stats.linkedServices}}</h4>
|
||||
<small>Linked services</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="well text-center">
|
||||
<h4>{{unlinkedWhmcsServices}}</h4>
|
||||
<small>Unlinked WHMCS services</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="well text-center">
|
||||
<h4>{{stats.pendingEvents}}</h4>
|
||||
<small>Pending GameCloud events</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped">
|
||||
<tbody>
|
||||
<tr><th>Addon version</th><td>{{version}}</td></tr>
|
||||
<tr><th>Integration ID</th><td>{{stats.integrationId}}</td></tr>
|
||||
<tr><th>Billing provider</th><td>{{stats.billingProvider}}</td></tr>
|
||||
<tr><th>Linked clients</th><td>{{stats.linkedClients}}</td></tr>
|
||||
<tr><th>Open reconciliation issues</th><td>{{stats.openReconciliationIssues}}</td></tr>
|
||||
<tr><th>Dead-letter events</th><td>{{stats.deadLetterEvents}}</td></tr>
|
||||
<tr><th>Usage exports</th><td>{{stats.exportedUsagePeriods}}</td></tr>
|
||||
<tr><th>Last reconciliation</th><td>{{stats.lastReconciliationAt}}</td></tr>
|
||||
<tr><th>Last event sync</th><td>{{stats.lastEventSyncAt}}</td></tr>
|
||||
<tr><th>Last error</th><td>{{lastError}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">GameCloud Events</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>
|
||||
<a href="{{modulelink}}" class="btn btn-default">Dashboard</a>
|
||||
<a href="{{modulelink}}&action=poll" class="btn btn-primary">Poll & auto-ack</a>
|
||||
</p>
|
||||
<p>Pending local copies: <strong>{{pendingCount}}</strong> · Dead letter: <strong>{{deadLetterCount}}</strong></p>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Payload</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{events.rows}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">Reconciliation</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>
|
||||
<a href="{{modulelink}}" class="btn btn-default">Dashboard</a>
|
||||
<a href="{{modulelink}}&action=reconciliation&run=1&dry=1" class="btn btn-default">Dry run</a>
|
||||
<a href="{{modulelink}}&action=reconciliation&run=1&repair=1" class="btn btn-warning">Live + auto-repair</a>
|
||||
</p>
|
||||
|
||||
{{reconciliation.result}}
|
||||
|
||||
<h4>Recent runs</h4>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run ID</th>
|
||||
<th>Mode</th>
|
||||
<th>Issues</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{reconciliation.rows}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,6 +100,11 @@ final class HexaGameCloudApiClient
|
||||
return $this->request('GET', '/api/v1/integrations/whmcs/accounts');
|
||||
}
|
||||
|
||||
public function dashboardStats(): array
|
||||
{
|
||||
return $this->request('GET', '/api/v1/integrations/whmcs/dashboard/stats');
|
||||
}
|
||||
|
||||
public function reconcileService(string $externalServiceId, array $payload = []): array
|
||||
{
|
||||
return $this->request(
|
||||
|
||||
@@ -253,6 +253,7 @@ export {
|
||||
whmcsUsageQuerySchema,
|
||||
whmcsUsageResponseSchema,
|
||||
whmcsUsageAdjustmentRequestSchema,
|
||||
whmcsDashboardStatsResponseSchema,
|
||||
type WhmcsReconcileRequest,
|
||||
type WhmcsReconcileResponse,
|
||||
type WhmcsListAccountsResponse,
|
||||
@@ -262,4 +263,5 @@ export {
|
||||
type WhmcsEventsAckResponse,
|
||||
type WhmcsUsageResponse,
|
||||
type WhmcsUsageAdjustmentRequest,
|
||||
type WhmcsDashboardStatsResponse,
|
||||
} from './whmcs';
|
||||
|
||||
@@ -268,3 +268,22 @@ 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>;
|
||||
|
||||
export const whmcsDashboardStatsResponseSchema = z.object({
|
||||
integrationId: z.string(),
|
||||
billingProvider: z.enum(['internal', 'whmcs', 'stripe']),
|
||||
moduleVersion: z.string(),
|
||||
linkedClients: z.number().int(),
|
||||
linkedServices: z.number().int(),
|
||||
suspendedServices: z.number().int(),
|
||||
terminatedServices: z.number().int(),
|
||||
pendingEvents: z.number().int(),
|
||||
deadLetterEvents: z.number().int(),
|
||||
openReconciliationIssues: z.number().int(),
|
||||
exportedUsagePeriods: z.number().int(),
|
||||
eventsCursor: z.string().uuid().nullable(),
|
||||
lastEventSyncAt: z.string().datetime().nullable(),
|
||||
lastReconciliationAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export type WhmcsDashboardStatsResponse = z.infer<typeof whmcsDashboardStatsResponseSchema>;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user