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

@@ -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)

View 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());
}
});

View File

@@ -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).

View File

@@ -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>';
}

View File

@@ -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 : [];
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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());

View File

@@ -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>

View File

@@ -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 &amp; 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>

View File

@@ -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>

View File

@@ -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(