Enhance API with OIDC support, including login and callback endpoints. Update environment variables for OIDC configuration in .env.example. Add new features to the catalog service for listing software families, Minecraft versions, and deployment regions. Implement server management actions such as kill, delete, and update in the servers module. Integrate feature flags for maintenance mode in server operations. Update pnpm-lock.yaml with new dependencies and versions.
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 12s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
TheOnlyMace
2026-07-05 18:39:53 +02:00
parent bf36cb3159
commit 50cd4b3ffd
225 changed files with 17824 additions and 436 deletions

View File

@@ -0,0 +1,17 @@
{
"name": "hexahost/whmcs-integration-dev",
"description": "Development dependencies for HexaHost GameCloud WHMCS integration tests",
"type": "project",
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"autoload-dev": {
"classmap": [
"modules/servers/hexagamecloud/lib/"
]
},
"scripts": {
"test": "phpunit",
"test:unit": "phpunit --testsuite Unit"
}
}

View File

@@ -1,7 +1,31 @@
# Server provisioning module placeholder
# HexaHost GameCloud WHMCS Server Module
Implementation planned in WHMCS Phase AB.
Provisioning module for WHMCS that integrates with the GameCloud Integration API.
Target install path: `/modules/servers/hexagamecloud/`
## Features
See `integrations/whmcs/README.md` for the full module layout.
- Provision, suspend, unsuspend, terminate, renew, change package
- Client-area SSO to the GameCloud panel
- Custom actions: **Start**, **Stop**, **Restart**, **Backup** (Integration API `actions/*`)
- Admin service tab with live reconcile snapshot
- DE/EN language files
## Configuration
| Field | Description |
|-------|-------------|
| Hostname | GameCloud API base URL (e.g. `https://api.example.net`) |
| Username | WHMCS integration ID |
| Password | Integration API secret |
## Build package
```bash
./build-package.sh
```
## Tests
```bash
composer test
```

View File

@@ -1,12 +1,18 @@
<?php
declare(strict_types=1);
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
require_once __DIR__ . '/lib/ApiClient.php';
require_once __DIR__ . '/lib/Idempotency.php';
require_once __DIR__ . '/lib/ServiceMetadata.php';
require_once __DIR__ . '/../../../lib/ProductMapping.php';
const HEXAGAMECLOUD_SERVER_VERSION = '1.1.0';
function hexagamecloud_MetaData(): array
{
return [
@@ -97,6 +103,36 @@ function hexagamecloud_TerminateAccount(array $params): string
return hexagamecloud_lifecycleAction($params, 'terminate');
}
function hexagamecloud_Renew(array $params): string
{
try {
$client = new HexaGameCloudApiClient($params);
$serviceId = (string) $params['serviceid'];
$payload = array_filter([
'invoiceId' => hexagamecloud_resolveRenewInvoiceId($params),
'periodEnd' => hexagamecloud_formatIso8601($params['nextduedate'] ?? null),
], static fn ($value) => $value !== null && $value !== '');
$client->renewService(
$serviceId,
$payload,
HexaGameCloudIdempotency::renewKey($serviceId, $params)
);
return 'success';
} catch (Throwable $exception) {
logModuleCall(
'hexagamecloud',
__FUNCTION__,
$params,
$exception->getMessage(),
$exception->getMessage(),
['password', 'secret', 'apiSecret']
);
return $exception->getMessage();
}
}
function hexagamecloud_ChangePackage(array $params): string
{
try {
@@ -121,6 +157,137 @@ function hexagamecloud_AdminSingleSignOn(array $params)
return hexagamecloud_requestSso($params, 'admin');
}
function hexagamecloud_ClientArea(array $params): array
{
hexagamecloud_loadLanguage($params);
$serviceId = (string) $params['serviceid'];
$snapshot = hexagamecloud_fetchServiceSnapshot($params);
$server = $snapshot['service']['server'] ?? [];
$linkStatus = (string) ($snapshot['service']['status'] ?? 'PENDING');
return [
'templatefile' => 'templates/clientarea',
'vars' => [
'LANG' => hexagamecloud_langVars(),
'apiWarning' => $snapshot['warning'],
'notProvisioned' => $linkStatus === 'PENDING' && ($server['id'] ?? '') === '',
'serverName' => (string) ($server['name'] ?? ($params['domain'] ?? '')),
'technicalStatus' => (string) ($server['status'] ?? 'UNKNOWN'),
'joinAddress' => (string) ($server['joinHostname'] ?? '-'),
'edition' => (string) ($server['edition'] ?? 'JAVA'),
'softwareFamily' => (string) ($server['softwareFamily'] ?? HexaGameCloudProductMapping::resolveSoftwareFamily($params)),
'minecraftVersion' => (string) ($server['minecraftVersion'] ?? HexaGameCloudProductMapping::resolveMinecraftVersion($params)),
'planSlug' => HexaGameCloudProductMapping::resolvePlanSlug($params),
'ramMb' => (int) ($server['ramMb'] ?? 0),
'nextDueDate' => (string) ($params['nextduedate'] ?? '-'),
'billingStatus' => $linkStatus === 'SUSPENDED'
? hexagamecloud_lang('hexagamecloud_suspended_yes')
: hexagamecloud_lang('hexagamecloud_suspended_no'),
'lastSync' => $snapshot['syncedAt'],
],
];
}
function hexagamecloud_ClientAreaCustomButtonArray(): array
{
return [
hexagamecloud_lang('hexagamecloud_manage_panel') => 'ManagePanel',
hexagamecloud_lang('hexagamecloud_action_start') => 'StartServer',
hexagamecloud_lang('hexagamecloud_action_stop') => 'StopServer',
hexagamecloud_lang('hexagamecloud_action_restart') => 'RestartServer',
hexagamecloud_lang('hexagamecloud_action_backup') => 'BackupServer',
];
}
function hexagamecloud_StartServer(array $params): string
{
return hexagamecloud_serviceAction($params, 'start');
}
function hexagamecloud_StopServer(array $params): string
{
return hexagamecloud_serviceAction($params, 'stop');
}
function hexagamecloud_RestartServer(array $params): string
{
return hexagamecloud_serviceAction($params, 'restart');
}
function hexagamecloud_BackupServer(array $params): string
{
return hexagamecloud_serviceAction($params, 'backup');
}
function hexagamecloud_serviceAction(array $params, string $action): string
{
try {
$client = new HexaGameCloudApiClient($params);
$serviceId = (string) $params['serviceid'];
if ($action === 'start') {
$client->startService($serviceId);
} elseif ($action === 'stop') {
$client->stopService($serviceId);
} elseif ($action === 'restart') {
$client->restartService($serviceId);
} else {
$client->backupService($serviceId);
}
return 'success';
} catch (Throwable $exception) {
logModuleCall('hexagamecloud', $action, $params, $exception->getMessage());
return $exception->getMessage();
}
}
function hexagamecloud_ManagePanel(array $params): array
{
return hexagamecloud_requestSso($params, 'service');
}
function hexagamecloud_AdminServicesTabFields(array $params): array
{
hexagamecloud_loadLanguage($params);
$serviceId = (string) $params['serviceid'];
$snapshot = hexagamecloud_fetchServiceSnapshot($params);
$service = $snapshot['service'];
$server = $service['server'] ?? [];
$metadata = HexaGameCloudServiceMetadata::load($serviceId);
$warning = $snapshot['warning'] !== null
? '<div class="alert alert-warning">' . htmlspecialchars($snapshot['warning'], ENT_QUOTES, 'UTF-8') . '</div>'
: '';
return [
hexagamecloud_lang('hexagamecloud_admin_server_id') => $warning . htmlspecialchars((string) ($server['id'] ?? '-'), ENT_QUOTES, 'UTF-8'),
hexagamecloud_lang('hexagamecloud_admin_user_id') => htmlspecialchars((string) ($server['userId'] ?? '-'), ENT_QUOTES, 'UTF-8'),
hexagamecloud_lang('hexagamecloud_admin_link_status') => htmlspecialchars((string) ($service['status'] ?? 'PENDING'), ENT_QUOTES, 'UTF-8'),
hexagamecloud_lang('hexagamecloud_admin_technical_status') => htmlspecialchars((string) ($server['status'] ?? 'UNKNOWN'), ENT_QUOTES, 'UTF-8'),
hexagamecloud_lang('hexagamecloud_admin_node') => htmlspecialchars((string) ($server['nodeId'] ?? '-'), ENT_QUOTES, 'UTF-8'),
hexagamecloud_lang('hexagamecloud_admin_host_port') => htmlspecialchars((string) ($server['hostPort'] ?? '-'), ENT_QUOTES, 'UTF-8'),
hexagamecloud_lang('hexagamecloud_plan') => htmlspecialchars(HexaGameCloudProductMapping::resolvePlanSlug($params), ENT_QUOTES, 'UTF-8'),
hexagamecloud_lang('hexagamecloud_ram') => htmlspecialchars((string) ((int) ($server['ramMb'] ?? 0)) . ' MiB', ENT_QUOTES, 'UTF-8'),
hexagamecloud_lang('hexagamecloud_admin_last_sync') => htmlspecialchars((string) ($snapshot['syncedAt'] ?? '-'), ENT_QUOTES, 'UTF-8'),
hexagamecloud_lang('hexagamecloud_admin_correlation_note') => '<textarea class="form-control" rows="3" name="hexagamecloud_correlation_note">'
. htmlspecialchars((string) ($metadata['correlation_note'] ?? ''), ENT_QUOTES, 'UTF-8')
. '</textarea>',
];
}
function hexagamecloud_AdminServicesTabFieldsSave(array $params): void
{
$serviceId = (string) $params['serviceid'];
$note = trim((string) ($params['hexagamecloud_correlation_note'] ?? ''));
$existing = HexaGameCloudServiceMetadata::load($serviceId);
HexaGameCloudServiceMetadata::save($serviceId, array_merge($existing, [
'correlation_note' => $note,
'updated_at' => gmdate('c'),
'updated_by_admin_id' => (string) ($params['adminid'] ?? ''),
]));
}
function hexagamecloud_TestConnection(array $params): array
{
try {
@@ -233,6 +400,103 @@ function hexagamecloud_lifecycleAction(array $params, string $action, array $bod
}
}
function hexagamecloud_fetchServiceSnapshot(array $params): array
{
$serviceId = (string) $params['serviceid'];
$cached = HexaGameCloudServiceMetadata::load($serviceId);
$snapshot = [
'service' => $cached['service'] ?? [
'externalServiceId' => $serviceId,
'status' => 'PENDING',
'server' => [],
],
'warning' => null,
'syncedAt' => $cached['synced_at'] ?? null,
];
try {
$client = new HexaGameCloudApiClient($params);
$service = $client->getService($serviceId);
$snapshot['service'] = $service;
$snapshot['syncedAt'] = gmdate('c');
HexaGameCloudServiceMetadata::save($serviceId, [
'service' => $service,
'synced_at' => $snapshot['syncedAt'],
'correlation_note' => $cached['correlation_note'] ?? '',
'updated_by_admin_id' => $cached['updated_by_admin_id'] ?? '',
]);
} catch (Throwable $exception) {
logModuleCall('hexagamecloud', 'fetchServiceSnapshot', $params, $exception->getMessage());
$snapshot['warning'] = hexagamecloud_lang('hexagamecloud_api_unavailable');
}
return $snapshot;
}
function hexagamecloud_resolveRenewInvoiceId(array $params): ?string
{
$invoiceId = (string) ($params['invoiceid'] ?? '');
if ($invoiceId !== '') {
return $invoiceId;
}
if (!empty($params['model']['invoiceid'])) {
return (string) $params['model']['invoiceid'];
}
return null;
}
function hexagamecloud_formatIso8601(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$timestamp = strtotime($value);
if ($timestamp === false) {
return null;
}
return gmdate('c', $timestamp);
}
function hexagamecloud_loadLanguage(array $params): void
{
static $loaded = false;
if ($loaded) {
return;
}
$language = strtolower((string) ($params['clientsdetails']['language'] ?? $params['language'] ?? 'english'));
if (!in_array($language, ['english', 'german'], true)) {
$language = 'english';
}
$langFile = __DIR__ . '/lang/' . $language . '.php';
if (!is_readable($langFile)) {
$langFile = __DIR__ . '/lang/english.php';
}
require $langFile;
$loaded = true;
}
function hexagamecloud_lang(string $key): string
{
global $_LANG;
return (string) ($_LANG[$key] ?? $key);
}
function hexagamecloud_langVars(): array
{
global $_LANG;
return is_array($_LANG ?? null) ? $_LANG : [];
}
function hexagamecloud_requestSso(array $params, string $kind)
{
try {

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
if (!defined('HEXAGAMECLOUD_SERVER_VERSION')) {
define('HEXAGAMECLOUD_SERVER_VERSION', '1.1.0');
}
add_hook('AfterModuleRenew', 1, function (array $vars): void {
if (($vars['params']['moduletype'] ?? '') !== 'hexagamecloud') {
return;
}
$serviceId = (string) ($vars['params']['serviceid'] ?? '');
if ($serviceId === '') {
return;
}
logActivity('HexaGameCloud service renewed via WHMCS (service #' . $serviceId . ')');
});
add_hook('AfterModuleCreate', 1, function (array $vars): void {
if (($vars['params']['moduletype'] ?? '') !== 'hexagamecloud') {
return;
}
$serviceId = (string) ($vars['params']['serviceid'] ?? '');
if ($serviceId === '') {
return;
}
logActivity('HexaGameCloud service provisioned (service #' . $serviceId . ')');
});
add_hook('ClientAreaPageProductDetails', 1, function (array $vars): array {
if (($vars['module'] ?? '') !== 'hexagamecloud') {
return [];
}
return [
'gamecloudModuleVersion' => HEXAGAMECLOUD_SERVER_VERSION,
];
});
add_hook('AdminAreaHeadOutput', 1, function (array $vars): string {
if (($vars['filename'] ?? '') !== 'clientsservices') {
return '';
}
return '<!-- HexaGameCloud server module ' . htmlspecialchars(HEXAGAMECLOUD_SERVER_VERSION, ENT_QUOTES, 'UTF-8') . ' -->';
});
add_hook('ServiceEdit', 1, function (array $vars): void {
if (($vars['moduletype'] ?? '') !== 'hexagamecloud') {
return;
}
$serviceId = (string) ($vars['serviceid'] ?? '');
if ($serviceId === '') {
return;
}
logActivity('HexaGameCloud service edited in WHMCS admin (service #' . $serviceId . ')');
});

View File

@@ -0,0 +1,35 @@
<?php
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
$_LANG['hexagamecloud_manage_panel'] = 'Manage in GameCloud Panel';
$_LANG['hexagamecloud_action_start'] = 'Start Server';
$_LANG['hexagamecloud_action_stop'] = 'Stop Server';
$_LANG['hexagamecloud_action_restart'] = 'Restart Server';
$_LANG['hexagamecloud_action_backup'] = 'Create Backup';
$_LANG['hexagamecloud_server_overview'] = 'Game Server Overview';
$_LANG['hexagamecloud_server_name'] = 'Server Name';
$_LANG['hexagamecloud_status'] = 'Status';
$_LANG['hexagamecloud_join_address'] = 'Join Address';
$_LANG['hexagamecloud_edition'] = 'Edition';
$_LANG['hexagamecloud_software'] = 'Software';
$_LANG['hexagamecloud_version'] = 'Version';
$_LANG['hexagamecloud_plan'] = 'Plan';
$_LANG['hexagamecloud_ram'] = 'RAM';
$_LANG['hexagamecloud_next_due'] = 'Next Billing Date';
$_LANG['hexagamecloud_suspend_status'] = 'Billing Suspension';
$_LANG['hexagamecloud_api_unavailable'] = 'GameCloud is temporarily unavailable. Cached data may be shown.';
$_LANG['hexagamecloud_last_sync'] = 'Last successful sync';
$_LANG['hexagamecloud_not_provisioned'] = 'This service has not been provisioned in GameCloud yet.';
$_LANG['hexagamecloud_admin_server_id'] = 'GameCloud Server ID';
$_LANG['hexagamecloud_admin_user_id'] = 'GameCloud User ID';
$_LANG['hexagamecloud_admin_link_status'] = 'Link Status';
$_LANG['hexagamecloud_admin_technical_status'] = 'Technical Status';
$_LANG['hexagamecloud_admin_node'] = 'Node';
$_LANG['hexagamecloud_admin_host_port'] = 'Host Port';
$_LANG['hexagamecloud_admin_correlation_note'] = 'Admin Correlation Note';
$_LANG['hexagamecloud_admin_last_sync'] = 'Last Sync';
$_LANG['hexagamecloud_suspended_yes'] = 'Suspended';
$_LANG['hexagamecloud_suspended_no'] = 'Active';

View File

@@ -0,0 +1,35 @@
<?php
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
$_LANG['hexagamecloud_manage_panel'] = 'Im GameCloud-Panel verwalten';
$_LANG['hexagamecloud_action_start'] = 'Server starten';
$_LANG['hexagamecloud_action_stop'] = 'Server stoppen';
$_LANG['hexagamecloud_action_restart'] = 'Server neu starten';
$_LANG['hexagamecloud_action_backup'] = 'Backup erstellen';
$_LANG['hexagamecloud_server_overview'] = 'Spieleserver-Übersicht';
$_LANG['hexagamecloud_server_name'] = 'Servername';
$_LANG['hexagamecloud_status'] = 'Status';
$_LANG['hexagamecloud_join_address'] = 'Join-Adresse';
$_LANG['hexagamecloud_edition'] = 'Edition';
$_LANG['hexagamecloud_software'] = 'Software';
$_LANG['hexagamecloud_version'] = 'Version';
$_LANG['hexagamecloud_plan'] = 'Tarif';
$_LANG['hexagamecloud_ram'] = 'Arbeitsspeicher';
$_LANG['hexagamecloud_next_due'] = 'Nächstes Abrechnungsdatum';
$_LANG['hexagamecloud_suspend_status'] = 'Abrechnungssperre';
$_LANG['hexagamecloud_api_unavailable'] = 'GameCloud ist vorübergehend nicht erreichbar. Es können zwischengespeicherte Daten angezeigt werden.';
$_LANG['hexagamecloud_last_sync'] = 'Letzte erfolgreiche Synchronisierung';
$_LANG['hexagamecloud_not_provisioned'] = 'Dieser Dienst wurde in GameCloud noch nicht bereitgestellt.';
$_LANG['hexagamecloud_admin_server_id'] = 'GameCloud-Server-ID';
$_LANG['hexagamecloud_admin_user_id'] = 'GameCloud-Benutzer-ID';
$_LANG['hexagamecloud_admin_link_status'] = 'Verknüpfungsstatus';
$_LANG['hexagamecloud_admin_technical_status'] = 'Technischer Status';
$_LANG['hexagamecloud_admin_node'] = 'Node';
$_LANG['hexagamecloud_admin_host_port'] = 'Host-Port';
$_LANG['hexagamecloud_admin_correlation_note'] = 'Admin-Korrelationsnotiz';
$_LANG['hexagamecloud_admin_last_sync'] = 'Letzte Synchronisierung';
$_LANG['hexagamecloud_suspended_yes'] = 'Gesperrt';
$_LANG['hexagamecloud_suspended_no'] = 'Aktiv';

View File

@@ -82,6 +82,24 @@ final class HexaGameCloudApiClient
);
}
public function getService(string $externalServiceId): array
{
return $this->request(
'GET',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId)
);
}
public function renewService(string $externalServiceId, array $payload, string $idempotencyKey): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/renew',
$payload,
$idempotencyKey
);
}
public function changePackage(string $externalServiceId, array $payload): array
{
return $this->request(
@@ -173,6 +191,46 @@ final class HexaGameCloudApiClient
);
}
public function startService(string $externalServiceId): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/start',
[],
'service:start:' . $externalServiceId
);
}
public function stopService(string $externalServiceId): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/stop',
[],
'service:stop:' . $externalServiceId
);
}
public function restartService(string $externalServiceId): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/restart',
[],
'service:restart:' . $externalServiceId
);
}
public function backupService(string $externalServiceId): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/backup',
[],
'service:backup:' . $externalServiceId
);
}
private function request(string $method, string $pathWithQuery, array $body = [], ?string $idempotencySuffix = null): array
{
$pathParts = explode('?', $pathWithQuery, 2);

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
final class HexaGameCloudIdempotency
{
public static function serviceOperationKey(string $operation, string $externalServiceId, ?string $reference = null): string
{
$suffix = ($reference !== null && $reference !== '') ? $reference : gmdate('Y-m-d');
return 'service:' . $operation . ':' . $externalServiceId . ':' . $suffix;
}
public static function renewKey(string $externalServiceId, array $params): string
{
$reference = (string) ($params['invoiceid'] ?? '');
if ($reference === '' && !empty($params['model']['invoiceid'])) {
$reference = (string) $params['model']['invoiceid'];
}
if ($reference === '' && !empty($params['renewalid'])) {
$reference = (string) $params['renewalid'];
}
return self::serviceOperationKey('renew', $externalServiceId, $reference !== '' ? $reference : null);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use WHMCS\Database\Capsule;
final class HexaGameCloudServiceMetadata
{
private const OPERATION_PREFIX = 'service:admin-meta:';
public static function load(string $serviceId): array
{
try {
$row = Capsule::table('mod_hexagamecloud_operations')
->where('operation', self::OPERATION_PREFIX . $serviceId)
->orderBy('id', 'desc')
->first();
if ($row === null || empty($row->metadata)) {
return [];
}
$decoded = json_decode((string) $row->metadata, true);
return is_array($decoded) ? $decoded : [];
} catch (Throwable) {
return [];
}
}
public static function save(string $serviceId, array $data): void
{
$payload = json_encode($data, JSON_THROW_ON_ERROR);
try {
$existing = Capsule::table('mod_hexagamecloud_operations')
->where('operation', self::OPERATION_PREFIX . $serviceId)
->orderBy('id', 'desc')
->first();
if ($existing !== null) {
Capsule::table('mod_hexagamecloud_operations')
->where('id', $existing->id)
->update([
'metadata' => $payload,
'status' => 'saved',
]);
return;
}
Capsule::table('mod_hexagamecloud_operations')->insert([
'operation' => self::OPERATION_PREFIX . $serviceId,
'status' => 'saved',
'metadata' => $payload,
'created_at' => gmdate('Y-m-d H:i:s'),
]);
} catch (Throwable $exception) {
logActivity('HexaGameCloud admin metadata save failed: ' . $exception->getMessage());
}
}
}

View File

@@ -0,0 +1,39 @@
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{$LANG.hexagamecloud_server_overview}</h3>
</div>
<div class="panel-body">
{if $apiWarning}
<div class="alert alert-warning">{$apiWarning}</div>
{/if}
{if $notProvisioned}
<p class="text-muted">{$LANG.hexagamecloud_not_provisioned}</p>
{else}
<dl class="dl-horizontal">
<dt>{$LANG.hexagamecloud_server_name}</dt>
<dd>{$serverName}</dd>
<dt>{$LANG.hexagamecloud_status}</dt>
<dd>{$technicalStatus}</dd>
<dt>{$LANG.hexagamecloud_join_address}</dt>
<dd>{$joinAddress}</dd>
<dt>{$LANG.hexagamecloud_edition}</dt>
<dd>{$edition}</dd>
<dt>{$LANG.hexagamecloud_software}</dt>
<dd>{$softwareFamily}</dd>
<dt>{$LANG.hexagamecloud_version}</dt>
<dd>{$minecraftVersion}</dd>
<dt>{$LANG.hexagamecloud_plan}</dt>
<dd>{$planSlug}</dd>
<dt>{$LANG.hexagamecloud_ram}</dt>
<dd>{$ramMb} MiB</dd>
<dt>{$LANG.hexagamecloud_next_due}</dt>
<dd>{$nextDueDate}</dd>
<dt>{$LANG.hexagamecloud_suspend_status}</dt>
<dd>{$billingStatus}</dd>
</dl>
{if $lastSync}
<p class="text-muted"><small>{$LANG.hexagamecloud_last_sync}: {$lastSync}</small></p>
{/if}
{/if}
</div>
</div>

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PACKAGING="${ROOT}/packaging"
MANIFEST="${PACKAGING}/manifest.json"
DIST="${ROOT}/dist"
STAGING="${DIST}/staging"
VERSION="$(python3 -c "import json; print(json.load(open('${MANIFEST}'))['version'])")"
PACKAGE_NAME="hexagamecloud-whmcs-${VERSION}"
ZIP_PATH="${DIST}/${PACKAGE_NAME}.zip"
CHECKSUMS_PATH="${DIST}/checksums.txt"
rm -rf "${STAGING}" "${ZIP_PATH}"
mkdir -p "${STAGING}/modules/servers" "${STAGING}/modules/addons" "${STAGING}/hooks" "${STAGING}/lib" "${DIST}"
copy_tree() {
local source="$1"
local target="$2"
if command -v rsync >/dev/null 2>&1; then
rsync -a --exclude '.gitkeep' "${source}/" "${target}/"
else
cp -a "${source}/." "${target}/"
fi
}
copy_tree "${ROOT}/modules/servers/hexagamecloud" "${STAGING}/modules/servers/hexagamecloud"
copy_tree "${ROOT}/modules/addons/hexagamecloud" "${STAGING}/modules/addons/hexagamecloud"
copy_tree "${ROOT}/hooks" "${STAGING}/hooks"
copy_tree "${ROOT}/lib" "${STAGING}/lib"
cp "${MANIFEST}" "${STAGING}/manifest.json"
(
cd "${STAGING}"
zip -r "${ZIP_PATH}" .
)
SHA256="$(sha256sum "${ZIP_PATH}" | awk '{print $1}')"
SHA512="$(sha512sum "${ZIP_PATH}" | awk '{print $1}')"
FILE_SIZE="$(stat -c '%s' "${ZIP_PATH}")"
BUILT_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
cat > "${CHECKSUMS_PATH}" <<EOF
# HexaHost GameCloud WHMCS package checksums
# version=${VERSION}
# built_at=${BUILT_AT}
SHA256 ${SHA256} ${PACKAGE_NAME}.zip
SHA512 ${SHA512} ${PACKAGE_NAME}.zip
SIZE ${FILE_SIZE} ${PACKAGE_NAME}.zip
EOF
rm -rf "${STAGING}"
echo "Built ${ZIP_PATH}"
echo "Checksums written to ${CHECKSUMS_PATH}"

View File

@@ -0,0 +1,43 @@
{
"name": "hexagamecloud-whmcs",
"displayName": "HexaHost GameCloud WHMCS Integration",
"version": "1.1.0",
"description": "Server provisioning and addon modules for HexaHost GameCloud billing integration.",
"author": "HexaHost",
"license": "Proprietary",
"modules": {
"server": {
"name": "hexagamecloud",
"path": "modules/servers/hexagamecloud",
"version": "1.1.0"
},
"addon": {
"name": "hexagamecloud",
"path": "modules/addons/hexagamecloud",
"version": "1.1.0"
}
},
"includes": [
"hooks/hexagamecloud.php",
"lib/MtlsConfig.php",
"lib/ProductMapping.php"
],
"compatibility": {
"whmcs": {
"minimum": "8.5.0",
"tested": ["8.5.0", "8.6.0", "8.7.0", "8.8.0", "8.9.0", "8.10.0", "8.11.0"]
},
"php": {
"minimum": "8.1.0",
"maximum": "8.3.99",
"extensions": ["curl", "json", "openssl"]
},
"gamecloud": {
"minimumApiVersion": "1.1"
}
},
"artifacts": {
"zip": "dist/hexagamecloud-whmcs-{version}.zip",
"checksums": "dist/checksums.txt"
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Contract">
<directory>tests/Contract</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@@ -0,0 +1,30 @@
# Contract tests
Contract tests validate WHMCS module requests and responses against the GameCloud integration API (`/api/v1/integrations/whmcs/*`).
## Scope
- Request signing headers (`X-HGC-Integration-ID`, `X-HGC-Signature`, `X-HGC-Idempotency-Key`, …)
- Canonical HMAC signature base string
- JSON request/response shapes from `packages/contracts/src/whmcs.ts`
- Idempotent lifecycle operations (`provision`, `suspend`, `renew`, `change-package`)
## Running
Contract tests require a reachable GameCloud API or a recorded mock server. They are not executed in CI by default.
```bash
# From integrations/whmcs after installing dev dependencies:
composer install
./vendor/bin/phpunit --testsuite Contract
```
## Fixtures
Sample WHMCS `$params` payloads and signed API examples will live under `tests/Fixtures/` as the contract suite grows.
## Related
- OpenAPI / Zod contracts: `packages/contracts/src/whmcs.ts`
- Server module client: `modules/servers/hexagamecloud/lib/ApiClient.php`
- Addon module client: `modules/addons/hexagamecloud/lib/ApiClient.php`

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once dirname(__DIR__, 2) . '/modules/servers/hexagamecloud/lib/Idempotency.php';
final class IdempotencyTest extends TestCase
{
public function testServiceOperationKeyUsesReferenceWhenProvided(): void
{
$key = HexaGameCloudIdempotency::serviceOperationKey('renew', '12345', 'invoice-99');
$this->assertSame('service:renew:12345:invoice-99', $key);
}
public function testServiceOperationKeyFallsBackToDateSuffix(): void
{
$key = HexaGameCloudIdempotency::serviceOperationKey('suspend', '42', null);
$this->assertStringStartsWith('service:suspend:42:', $key);
$this->assertMatchesRegularExpression('/^service:suspend:42:\d{4}-\d{2}-\d{2}$/', $key);
}
public function testRenewKeyPrefersInvoiceId(): void
{
$key = HexaGameCloudIdempotency::renewKey('9001', [
'invoiceid' => '7788',
'renewalid' => '5555',
]);
$this->assertSame('service:renew:9001:7788', $key);
}
public function testRenewKeyUsesRenewalIdWhenInvoiceMissing(): void
{
$key = HexaGameCloudIdempotency::renewKey('9001', [
'renewalid' => '5555',
]);
$this->assertSame('service:renew:9001:5555', $key);
}
public function testRenewKeyUsesModelInvoiceId(): void
{
$key = HexaGameCloudIdempotency::renewKey('9001', [
'model' => ['invoiceid' => '6611'],
]);
$this->assertSame('service:renew:9001:6611', $key);
}
}