diff --git a/CLAUDE.md b/CLAUDE.md index d3ca316..7bec4cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,7 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene | `PowerDns\IpUtil` | Pure helpers: `ptrNameForIp` (v4/v6 nibble reversal), `expandIpv6`, `extractIps` (all interfaces), `findZoneAndPtrName` (standard + RFC 2317 classless), `parseClasslessZone`. | | `PowerDns\Resolver` | Forward-DNS verification via `dns_get_record()` with up-to-5-hop CNAME following. Cached per (hostname, ip) pair. | | `PowerDns\PtrManager` | Orchestrator: `syncServer`, `deleteForServer`, `listPtrs`, `setPtr`, `reconcile`, `reconcileAll`. Per-request zone cache. 10s per-IP write rate limit. Enforces FCrDNS before writes. | +| `StockControl` | Orchestrator for dynamic inventory. `recalculateForProduct()` and `recalculateAll()` compute per-product qty from live `/packages/{id}` + `/compute/hypervisors/groups/{id}/resources` data and write to `tblproducts.qty`. Fail-safe: null return = qty untouched. | ### Class Hierarchy @@ -134,6 +135,38 @@ Opt-in integration via the companion `VirtFusionDns` addon module. Loose-coupled Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from `-example.php`). Default mapping keys: `packageId`, `hypervisorId`, `ipv4`, `storage`, `memory`, `traffic`, `cpuCores`, `networkSpeedInbound`, `networkSpeedOutbound`, `networkProfile`, `storageProfile`. +### Inventory / Stock Control + +Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockcontrol=1`). When enabled, the module overwrites `tblproducts.qty` with the real number of VPSes that can still be provisioned — WHMCS then handles the "Out of Stock" badge, Add-to-Cart gating, and checkout refusal natively. No templates or JS required. + +**Data sources (authoritative):** +- `GET /packages/{id}` — per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`, `enabled`) +- `GET /compute/hypervisors/groups/{id}/resources` — live free/allocated per hypervisor with per-metric quotas, storage pools (matched by package.primaryStorageProfile), and a group-level IPv4 pool + +**Algorithm:** for every group the product can be placed in (default `configoption1` plus every numeric value of the `Location` configurable option), sum `min(memory, cpu, storage)` across eligible hypervisors (enabled AND commissioned AND !prohibit) and cap by the group-level IPv4 pool (`max` across hypervisors, not summed — IPv4 is a single group-wide pool). Sum across groups → qty. + +**Triggers:** +- `AfterModuleCreate` — post-provision refresh; bursts rate-limited to one recalc per 30 s via `stockrefresh:event` cache key. +- `AfterModuleTerminate` — post-termination refresh; shares the same 30 s rate-limit key. +- `AfterCronJob` — every-2-hour safety net (captures out-of-band VirtFusion panel changes). Tunable via `STOCK_CRON_INTERVAL_SECONDS` constant in `hooks.php`. +- `ClientAreaPageCart` — opportunistic per-product refresh on cart/order pages with a 60 s rate-limit key (`stockrefresh:{pid}`). The `grpres:{id}` cache (120 s TTL) naturally coalesces bursts. +- `admin.php?action=stockRecalculate` — admin-triggered full recalc (POST + same-origin required); returns JSON `{productId: qty}` map. + +**Order auto-accept:** `AfterModuleCreate` additionally calls WHMCS `AcceptOrder` with `autosetup=false` when the service's parent order is still Pending. Closes the loop for installs that rely on pending-order workflows for non-VF products but want VF provisions to auto-advance. + +**Caching:** `pkg:{id}` 600 s (package definitions rarely change), `grpres:{id}` 120 s (resources change under load). Confirmed 404s cached 60 s so re-creating a deleted package/group takes effect quickly. + +**Safety properties:** +- Transient API failures (null from `fetchPackage` / `fetchGroupResources`) leave `qty` UNTOUCHED — never silently takes the catalogue offline. +- Confirmed-missing conditions (HTTP 404 on package, `package.enabled=false`) return qty=0 — the product genuinely cannot be provisioned. +- IPv4 cap is max-within-group (not summed across hypervisors) to avoid double-counting the shared pool. +- Storage match is strict: the package's `primaryStorageProfile` must exist and be enabled on the target hypervisor, otherwise that hypervisor contributes 0. Falls back to `localStorage` only when the package has no profile set. +- Stock control is gated by `tblproducts.stockcontrol=1` per product — the module never touches qty on products that opt out. + +**Per-product setting:** `stockSafetyBufferPct` (configoption7, default 10). Reserves X% of each resource's `max` before computing fits; ignored for unlimited resources (`max=0`) and for IPv4 (no per-hypervisor `max` in the response). Admins can override per product in the module settings; blank falls back to 10%. + +**API scope required:** the VirtFusion API token must have read access to both `/packages` and `/compute/hypervisors/groups`. The Test Connection button probes the compute endpoint and shows a clear error if scope is missing. + ## Security Patterns - All PHP files start with `if (!defined("WHMCS")) die()` to prevent direct access (except entry points using `init.php`) @@ -173,6 +206,7 @@ Custom option names can be mapped in `config/ConfigOptionMapping.php` (copy from | configoption4 | Self-Service Mode | 0=Disabled, 1=Hourly, 2=Resource Packs, 3=Both | 0 | | configoption5 | Auto Top-Off Threshold | Credit balance below which auto top-off triggers | 0 | | configoption6 | Auto Top-Off Amount | Credit amount to add on auto top-off | 100 | +| configoption7 | Stock Safety Buffer (%) | Headroom reserved per resource during stock calculation (0-100). Only effective with WHMCS stock control enabled. Blank falls back to the default. | 10 | ## WHMCS Compatibility diff --git a/modules/servers/VirtFusionDirect/VirtFusionDirect.php b/modules/servers/VirtFusionDirect/VirtFusionDirect.php index 5beb5e7..abc01fb 100644 --- a/modules/servers/VirtFusionDirect/VirtFusionDirect.php +++ b/modules/servers/VirtFusionDirect/VirtFusionDirect.php @@ -114,6 +114,13 @@ function VirtFusionDirect_ConfigOptions() 'Description' => 'Credit amount to add when auto top-off triggers.', 'Default' => '100', ], + 'stockSafetyBufferPct' => [ + 'FriendlyName' => 'Stock Safety Buffer (%)', + 'Type' => 'text', + 'Size' => '5', + 'Description' => 'Reserved headroom applied per resource when calculating stock. Only effective when the WHMCS Stock Control toggle is enabled on this product. 0-100; ignored for resources with no quota set in VirtFusion. Default is 10% if left blank.', + 'Default' => '10', + ], ]; } @@ -135,6 +142,28 @@ function VirtFusionDirect_TestConnection(array $params) $httpCode = $request->getRequestInfo('http_code'); if ($httpCode == 200) { + // Probe the compute scope: stock control depends on read access to + // /compute/hypervisors/groups. A token scoped only to /servers will pass the + // /connect check above but silently break nightly stock recalculation, so we + // surface the missing scope at config time rather than a week later. + $groupsProbe = $module->initCurl($password); + $groupsProbe->get($url . '/compute/hypervisors/groups?results=1'); + $groupsHttp = (int) $groupsProbe->getRequestInfo('http_code'); + + if ($groupsHttp === 401 || $groupsHttp === 403) { + return [ + 'success' => false, + 'error' => 'VirtFusion OK but API token lacks read access to /compute/hypervisors/groups (HTTP ' . $groupsHttp . '). Stock Control will not work — re-issue the token with compute:read scope.', + ]; + } + + if ($groupsHttp !== 200) { + return [ + 'success' => false, + 'error' => 'VirtFusion OK but /compute/hypervisors/groups returned HTTP ' . $groupsHttp . '. Stock Control may not work correctly.', + ]; + } + // Also verify PowerDNS health when the DNS addon is activated, so the // admin's Test Connection button reflects the full provisioning path. if (PowerDnsConfig::isEnabled()) { diff --git a/modules/servers/VirtFusionDirect/admin.php b/modules/servers/VirtFusionDirect/admin.php index 10239cf..24e20f0 100644 --- a/modules/servers/VirtFusionDirect/admin.php +++ b/modules/servers/VirtFusionDirect/admin.php @@ -39,6 +39,7 @@ use WHMCS\Module\Server\VirtFusionDirect\Module; use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig; use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager; use WHMCS\Module\Server\VirtFusionDirect\ServerResource; +use WHMCS\Module\Server\VirtFusionDirect\StockControl; $vf = new Module; @@ -169,6 +170,46 @@ try { $vf->output(['success' => true, 'data' => $summary], true, true, 200); break; + // ================================================================= + // Stock Control + // ================================================================= + + /** + * Force a full stock-quantity recalculation across every VirtFusionDirect + * product that has WHMCS stock control enabled. Same logic as the 2-hour + * AfterCronJob safety-net hook and the post-provision / post-termination + * event hooks in hooks.php, but on-demand. Cache TTLs still govern freshness + * of the underlying VirtFusion API reads — run a separate cache bust first + * if the admin needs to bypass the 120 s grpres:{id} TTL. + * + * Usable by admins via POST; returns a JSON map of productId => qty (or null + * where the product was skipped / left untouched by the orchestrator). + */ + case 'stockRecalculate': + + $vf->requirePost(); + $vf->requireSameOrigin(); + + $results = (new StockControl)->recalculateAll(); + + // Log a compact summary instead of the full map — the admin client still + // gets the detailed per-product map in the JSON response, but the module + // log stays readable even on stores with hundreds of VirtFusion products. + $summary = ['total' => count($results), 'updated' => 0, 'zeroed' => 0, 'skipped' => 0]; + foreach ($results as $qty) { + if ($qty === null) { + $summary['skipped']++; + } elseif ((int) $qty === 0) { + $summary['zeroed']++; + } else { + $summary['updated']++; + } + } + Log::insert('stockRecalculate:ok', [], $summary); + + $vf->output(['success' => true, 'data' => $results], true, true, 200); + break; + default: $vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400); } diff --git a/modules/servers/VirtFusionDirect/hooks.php b/modules/servers/VirtFusionDirect/hooks.php index bdb1f04..cdf8530 100644 --- a/modules/servers/VirtFusionDirect/hooks.php +++ b/modules/servers/VirtFusionDirect/hooks.php @@ -19,7 +19,12 @@ * * HOOKS REGISTERED HERE * --------------------- + * DailyCronJob — PowerDNS reconciliation across all services + * AfterCronJob — Every-2-hour stock recalculation safety net + * AfterModuleCreate — Stock refresh + order auto-accept after a VPS provisions + * AfterModuleTerminate — Stock refresh after a VPS is destroyed + * ClientAreaPageCart — Lazy per-product stock refresh during the order flow * ShoppingCartValidateCheckout — blocks checkout until OS is selected * ClientAreaFooterOutput — injects the OS/SSH-key gallery on order form * @@ -32,12 +37,14 @@ */ use WHMCS\Database\Capsule; +use WHMCS\Module\Server\VirtFusionDirect\Cache; use WHMCS\Module\Server\VirtFusionDirect\ConfigureService; use WHMCS\Module\Server\VirtFusionDirect\Database; use WHMCS\Module\Server\VirtFusionDirect\Log; use WHMCS\Module\Server\VirtFusionDirect\Module; use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig; use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager; +use WHMCS\Module\Server\VirtFusionDirect\StockControl; if (! defined('WHMCS')) { exit('This file cannot be accessed directly'); @@ -63,6 +70,190 @@ add_hook('DailyCronJob', 1, function ($vars) { } }); +/** + * Every-~2-hour stock recalculation safety net. + * + * Events (AfterModuleCreate/Terminate) cover every capacity change driven + * through WHMCS. But an operator can also create/destroy VMs directly in the + * VirtFusion panel — no WHMCS hook fires for that, so stock qty would drift + * until the next cart-page visit or the next event-driven refresh. This hook + * closes that blind spot. + * + * AfterCronJob fires on every main WHMCS cron invocation (typically every + * 5 minutes). Cache::get on the rate-limit key means the hook is effectively + * free on the 99% of invocations where no recalc is due — one cache read, + * return. The actual recalc only runs when the key has expired. + * + * Interval: 2 hours. Tunable via the STOCK_CRON_INTERVAL_SECONDS constant + * below. Short enough that out-of-band VirtFusion panel changes surface the + * same business day; long enough that the storefront isn't writing + * tblproducts.qty every five minutes. + * + * FAIL-SAFE: StockControl::recalculateAll() returns a map of productId => + * qty|null, where null means the orchestrator left qty UNTOUCHED (transient + * API failure, missing CP, etc.). Our catch here only fires on truly unexpected + * errors that escape the orchestrator itself. + */ +const STOCK_CRON_INTERVAL_SECONDS = 2 * 3600; // 2 hours + +add_hook('AfterCronJob', 5, function ($vars) { + try { + $rateKey = 'stockrefresh:cron'; + if (Cache::get($rateKey) !== null) { + return; + } + Cache::set($rateKey, 1, STOCK_CRON_INTERVAL_SECONDS); + + (new StockControl)->recalculateAll(); + } catch (Throwable $e) { + Log::insert('StockControl:AfterCronJob', [], $e->getMessage()); + } +}); + +/** + * Post-provision: auto-accept the originating order and refresh stock. + * + * Fires after every successful VirtFusion CreateAccount. Two responsibilities, + * independent try/catch blocks so a failure in one doesn't short-circuit the other: + * + * 1. AUTO-ACCEPT — if the service's parent order is still 'Pending' (admin + * hasn't manually accepted yet), call WHMCS's AcceptOrder API with + * autosetup=false (we already provisioned, don't re-trigger CreateAccount). + * This closes the loop for installs that rely on pending-order workflows + * for non-VF products but want VF provisions to auto-advance. + * + * 2. STOCK REFRESH — a new VM just consumed memory/cpu/disk/IPv4 on the + * target hypervisor group. Bust the grpres:{id} cache and recalculate + * every stock-controlled product. A shared 30 s rate-limit key prevents + * a burst of 10 parallel provisions from triggering 10 full recalcs. + * + * Filtering by moduletype='VirtFusionDirect' keeps this hook harmless for + * unrelated products that happen to share the WHMCS install. + */ +add_hook('AfterModuleCreate', 1, function ($vars) { + if (($vars['params']['moduletype'] ?? '') !== 'VirtFusionDirect') { + return; + } + + // Part 1: auto-accept the originating order if still Pending. + try { + $serviceId = (int) ($vars['params']['serviceid'] ?? 0); + if ($serviceId > 0) { + $hosting = Capsule::table('tblhosting')->where('id', $serviceId)->first(); + $orderId = $hosting ? (int) ($hosting->orderid ?? 0) : 0; + if ($orderId > 0) { + $order = Capsule::table('tblorders')->where('id', $orderId)->first(); + if ($order && strcasecmp((string) $order->status, 'Pending') === 0) { + $resp = localAPI('AcceptOrder', [ + 'orderid' => $orderId, + 'autosetup' => false, // already provisioned; don't re-run CreateAccount + 'sendemail' => true, + ]); + Log::insert( + 'AutoAcceptOrder', + ['orderid' => $orderId, 'serviceid' => $serviceId], + $resp, + ); + } + } + } + } catch (Throwable $e) { + Log::insert('AutoAcceptOrder:fail', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage()); + } + + // Part 2: refresh stock (capacity just decreased). + try { + if (Cache::get('stockrefresh:event') === null) { + Cache::set('stockrefresh:event', 1, 30); + + $groupId = (int) ($vars['params']['configoption1'] ?? 0); + if ($groupId > 0) { + Cache::forget('grpres:' . $groupId); + } + + (new StockControl)->recalculateAll(); + } + } catch (Throwable $e) { + Log::insert('StockControl:AfterModuleCreate', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage()); + } +}); + +/** + * Post-termination stock refresh. + * + * A destroyed VM just freed memory/cpu/disk/IPv4 on the target hypervisor group. + * Refresh so the storefront reflects the restored capacity immediately. Shares + * the 30 s rate-limit key with AfterModuleCreate — a provision-then-terminate in + * quick succession only triggers one full recalc. + */ +add_hook('AfterModuleTerminate', 1, function ($vars) { + if (($vars['params']['moduletype'] ?? '') !== 'VirtFusionDirect') { + return; + } + + try { + if (Cache::get('stockrefresh:event') !== null) { + return; + } + Cache::set('stockrefresh:event', 1, 30); + + $groupId = (int) ($vars['params']['configoption1'] ?? 0); + if ($groupId > 0) { + Cache::forget('grpres:' . $groupId); + } + + (new StockControl)->recalculateAll(); + } catch (Throwable $e) { + Log::insert('StockControl:AfterModuleTerminate', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage()); + } +}); + +/** + * Lazy stock refresh on order-flow cart pages. + * + * Keeps "hot" products fresh between daily cron runs without a polling loop: when a + * customer lands on a cart page for a specific product, we opportunistically recalculate + * that product's qty. If the upstream grpres:{id} cache is warm (populated in the last + * 120 s by an earlier view or the daily cron), recalculateForProduct does no HTTP calls + * and just re-writes the same qty — effectively free. + * + * WHY ClientAreaPageCart (not ClientAreaPageProductDetails) + * --------------------------------------------------------- + * ClientAreaPageProductDetails fires on the My Services → product-details view for an + * EXISTING service, which is the wrong place — the stock number only matters during + * pre-order. ClientAreaPageCart fires on every cart/order page (product browse, config, + * checkout) and WHMCS consults tblproducts.qty on each of those, so this is where a + * fresh number pays off. + * + * RATE LIMIT + * ---------- + * 60 s per product (stockrefresh:{pid}). Short enough that a busy product refreshes + * near-continuously across viewers; long enough that two customers arriving within the + * same second don't trigger two identical DB UPDATEs. The pid check below filters this + * hook to only fire when a specific product is known — generic cart pages (templatefile= + * "cart.tpl") pass no pid and are no-ops. + */ +add_hook('ClientAreaPageCart', 1, function ($vars) { + try { + $productId = (int) ($vars['pid'] ?? $vars['productid'] ?? ($vars['productinfo']['pid'] ?? 0)); + if ($productId <= 0) { + return null; + } + + $rateKey = 'stockrefresh:' . $productId; + if (Cache::get($rateKey) !== null) { + return null; + } + Cache::set($rateKey, 1, 60); + + (new StockControl)->recalculateForProduct($productId); + } catch (Throwable $e) { + Log::insert('StockControl:ClientAreaPageCart', ['pid' => $vars['pid'] ?? null], $e->getMessage()); + } + + return null; +}); + /** * Shopping Cart Validation Hook * diff --git a/modules/servers/VirtFusionDirect/lib/Module.php b/modules/servers/VirtFusionDirect/lib/Module.php index 84241d0..3189ca1 100644 --- a/modules/servers/VirtFusionDirect/lib/Module.php +++ b/modules/servers/VirtFusionDirect/lib/Module.php @@ -56,6 +56,14 @@ use WHMCS\Database\Capsule; */ class Module { + /** + * @var array|false|null Memoised catalogue-level CP connection used by fetchPackage/fetchGroupResources. + * Resolved via getCP(false, true) — "any available VirtFusion server" — on first use. + * Kept on the instance so a cron loop recalculating 20 products doesn't hit + * tblservers 20×N times when N stock helpers are called per product. + */ + private $catalogueCp = null; + /** * Initialises the module and ensures the database schema is up to date. */ @@ -1240,4 +1248,175 @@ class Module { return json_decode($response, true, 512, JSON_THROW_ON_ERROR); } + + // ========================================================================= + // Catalogue helpers — used by StockControl to size the WHMCS inventory from + // live VirtFusion data. Pre-order code path: CP is resolved via "any + // available server" since no service context exists yet. + // ========================================================================= + + /** + * Resolve the catalogue-level CP (any available VirtFusion server) and memoise. + * + * Stock calculations run from a cron loop or product-detail page view — there's + * no WHMCS service yet, so we can't dereference a specific panel via + * resolveServiceContext. "Any enabled server" is the correct fallback for read-only + * catalogue operations (package + hypervisor-group endpoints return the same data + * from every VirtFusion node on the same cluster). + * + * @return array{url: string, base_url: string, token: string}|false + */ + private function getCatalogueCp() + { + if ($this->catalogueCp === null) { + $this->catalogueCp = $this->getCP(false, true); + } + + return $this->catalogueCp; + } + + /** + * Fetch a VirtFusion package by ID — the authoritative source for "how much RAM, + * CPU, and disk does one VPS of this product cost?". + * + * Return values distinguish confirmed-missing from transient failure: + * array — package data (fields: memory, cpuCores, primaryStorage, primaryStorageProfile, enabled, …) + * false — HTTP 404: package has been deleted in VirtFusion. Callers treat as OOS. + * null — Transient failure (no CP, network error, 5xx, malformed body). Callers must + * NOT overwrite WHMCS qty on a null — that would zero out inventory during a blip. + * + * Success responses are cached 10 min (key "pkg:{id}") since package definitions + * rarely change; 404 responses get a short 60 s cache so an admin re-creating a + * deleted package doesn't have to wait ten minutes for stock to pick it up again. + * + * @param int $packageId VirtFusion package ID (from tblproducts.configoption2). + * @return array|false|null + */ + public function fetchPackage($packageId) + { + try { + $packageId = (int) $packageId; + if ($packageId <= 0) { + return null; + } + + $cacheKey = 'pkg:' . $packageId; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + // Sentinel marker for a previously-confirmed 404. + if (is_array($cached) && ! empty($cached['__notFound'])) { + return false; + } + + return $cached; + } + + $cp = $this->getCatalogueCp(); + if (! $cp) { + return null; + } + + $request = $this->initCurl($cp['token']); + $data = $request->get($cp['url'] . '/packages/' . $packageId); + Log::insert(__FUNCTION__, $request->getRequestInfo(), $data); + + $httpCode = (int) $request->getRequestInfo('http_code'); + + if ($httpCode === 200) { + $decoded = json_decode($data, true); + if (is_array($decoded)) { + $package = $decoded['data'] ?? $decoded; + if (is_array($package)) { + Cache::set($cacheKey, $package, 600); + + return $package; + } + } + + return null; + } + + if ($httpCode === 404) { + Cache::set($cacheKey, ['__notFound' => true], 60); + + return false; + } + + return null; + } catch (\Throwable $e) { + Log::insert(__FUNCTION__, [], $e->getMessage()); + + return null; + } + } + + /** + * Fetch free/allocated resources for every hypervisor in a group — the live picture + * of how much headroom remains to place more VPSes. + * + * Same tri-state return contract as fetchPackage(): + * array — decoded response with a 'data' array of per-hypervisor resource breakdowns. + * false — HTTP 404: group has been deleted. Callers may treat as "zero capacity from this group". + * null — Transient failure. Callers must NOT overwrite WHMCS qty on a null. + * + * Cache TTL is 120 s — short enough that customers don't see stale OOS labels for + * long after capacity frees up, and long enough to amortise the upstream call across + * bursty product-page traffic. Matches the traffic-stats TTL in getTrafficStats(). + * + * @param int $groupId VirtFusion hypervisor group ID. + * @return array|false|null + */ + public function fetchGroupResources($groupId) + { + try { + $groupId = (int) $groupId; + if ($groupId <= 0) { + return null; + } + + $cacheKey = 'grpres:' . $groupId; + $cached = Cache::get($cacheKey); + if ($cached !== null) { + if (is_array($cached) && ! empty($cached['__notFound'])) { + return false; + } + + return $cached; + } + + $cp = $this->getCatalogueCp(); + if (! $cp) { + return null; + } + + $request = $this->initCurl($cp['token']); + $data = $request->get($cp['url'] . '/compute/hypervisors/groups/' . $groupId . '/resources'); + Log::insert(__FUNCTION__, $request->getRequestInfo(), $data); + + $httpCode = (int) $request->getRequestInfo('http_code'); + + if ($httpCode === 200) { + $decoded = json_decode($data, true); + if (is_array($decoded) && isset($decoded['data']) && is_array($decoded['data'])) { + Cache::set($cacheKey, $decoded, 120); + + return $decoded; + } + + return null; + } + + if ($httpCode === 404) { + Cache::set($cacheKey, ['__notFound' => true], 60); + + return false; + } + + return null; + } catch (\Throwable $e) { + Log::insert(__FUNCTION__, [], $e->getMessage()); + + return null; + } + } } diff --git a/modules/servers/VirtFusionDirect/lib/StockControl.php b/modules/servers/VirtFusionDirect/lib/StockControl.php new file mode 100644 index 0000000..f5df2d8 --- /dev/null +++ b/modules/servers/VirtFusionDirect/lib/StockControl.php @@ -0,0 +1,537 @@ + 'IPv4', + 'packageId' => 'Package', + 'hypervisorId' => 'Location', + 'storage' => 'Storage', + 'memory' => 'Memory', + 'traffic' => 'Bandwidth', + 'networkSpeedInbound' => 'Inbound Network Speed', + 'networkSpeedOutbound' => 'Outbound Network Speed', + 'cpuCores' => 'CPU Cores', + 'networkProfile' => 'Network Type', + 'storageProfile' => 'Storage Type', + ]; + + /** @var Module Shared for its CP memoisation + initCurl/fetchPackage/fetchGroupResources helpers. */ + private $module; + + /** @var array|null Resolved per-request once. */ + private $optionLabelMap = null; + + public function __construct(?Module $module = null) + { + // Dependency-inject for testability; default wires up a real Module so production + // callers (hooks.php, admin.php) don't have to know about the dependency. + $this->module = $module ?? new Module; + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + /** + * Recalculate qty for every VirtFusionDirect product that has WHMCS stock control enabled. + * + * Called from the every-2-hour AfterCronJob safety-net hook, from the post-provision + * and post-termination event hooks in hooks.php, and from the admin stockRecalculate + * AJAX endpoint in admin.php. Returns a map of productId => resulting qty (or null + * where the product was skipped / left untouched), useful for the admin endpoint's + * JSON response and for per-event logging. + * + * @return array + */ + public function recalculateAll(): array + { + $results = []; + + try { + $products = DB::table('tblproducts') + ->where('servertype', 'VirtFusionDirect') + ->where('stockcontrol', 1) + ->get(); + + foreach ($products as $product) { + $results[(int) $product->id] = $this->recalculateForProduct((int) $product->id); + } + } catch (\Throwable $e) { + Log::insert('StockControl:recalculateAll', [], $e->getMessage()); + } + + return $results; + } + + /** + * Recalculate qty for a single product. + * + * Returns the new qty on success, or null on any unrecoverable failure — in which case + * tblproducts.qty is left unchanged (fail-safe invariant). + */ + public function recalculateForProduct(int $productId): ?int + { + try { + $product = DB::table('tblproducts')->where('id', $productId)->first(); + if (! $product) { + return null; + } + if ($product->servertype !== 'VirtFusionDirect') { + return null; + } + if ((int) $product->stockcontrol !== 1) { + // Stock control disabled on this product — don't manage qty. + return null; + } + + $qty = $this->computeQtyForProduct($product); + if ($qty === null) { + // Transient / unrecoverable — preserve existing qty. + return null; + } + + DB::table('tblproducts') + ->where('id', $productId) + ->update(['qty' => (int) $qty]); + + Log::insert( + 'StockControl:recalculate', + [ + 'productId' => $productId, + 'packageId' => (int) $product->configoption2, + 'defaultGroupId' => (int) $product->configoption1, + ], + ['qty' => $qty], + ); + + return $qty; + } catch (\Throwable $e) { + Log::insert('StockControl:recalculateForProduct', ['productId' => $productId], $e->getMessage()); + + return null; + } + } + + // ----------------------------------------------------------------------- + // Computation + // ----------------------------------------------------------------------- + + /** + * Compute the qty integer without touching the DB. + * + * @param object $product tblproducts row. + * @return int|null Non-negative qty, or null when the computation cannot complete. + */ + private function computeQtyForProduct($product): ?int + { + $productId = (int) $product->id; + + $packageId = (int) $product->configoption2; + if ($packageId <= 0) { + Log::insert( + 'StockControl:compute', + ['productId' => $productId], + 'no packageId in configoption2 — skipped', + ); + + return null; + } + + $package = $this->module->fetchPackage($packageId); + if ($package === null) { + // Transient — preserve qty. + return null; + } + if ($package === false) { + // Confirmed 404: package deleted in VirtFusion. Product is unfulfillable. + Log::insert( + 'StockControl:compute', + ['productId' => $productId, 'packageId' => $packageId], + 'package 404 — qty forced to 0', + ); + + return 0; + } + if (empty($package['enabled'])) { + Log::insert( + 'StockControl:compute', + ['productId' => $productId, 'packageId' => $packageId], + 'package disabled in VirtFusion — qty forced to 0', + ); + + return 0; + } + + $groupIds = $this->resolveHypervisorGroupIds($product); + if (empty($groupIds)) { + Log::insert( + 'StockControl:compute', + ['productId' => $productId], + 'no hypervisor groups resolved — qty untouched', + ); + + return null; + } + + $ipv4Required = max(1, (int) ($product->configoption3 ?? 1)); + $bufferPct = $this->bufferPctForProduct($product); + + $total = 0; + foreach ($groupIds as $groupId) { + $resources = $this->module->fetchGroupResources($groupId); + if ($resources === null) { + // Transient failure on any group aborts the whole computation — we can't + // safely reduce qty to a partial total and risk under-reporting stock. + return null; + } + if ($resources === false) { + // Group 404 — deleted; contributes 0. Keep going so other eligible groups still count. + Log::insert( + 'StockControl:compute', + ['productId' => $productId, 'groupId' => $groupId], + 'group 404 — contributing 0 capacity', + ); + + continue; + } + + $total += $this->groupCapacity($resources, $package, $ipv4Required, $bufferPct); + } + + return max(0, $total); + } + + /** + * Sum of per-hypervisor minimums (mem/cpu/storage), capped by the group-level IPv4 pool. + * + * IPv4 CAP IS MAX-WITHIN-GROUP, NOT SUMMED + * ---------------------------------------- + * network.total.ipv4.free in the API is a group-level pool visible from every hypervisor + * in the group — the same number is reported on each. Summing per-hypervisor IPv4 caps + * would overcount the pool by the hypervisor count. Taking max() within a group, then + * summing across groups, reflects the real constraint. + */ + private function groupCapacity(array $resources, array $package, int $ipv4Required, float $bufferPct): int + { + $hypervisors = $resources['data'] ?? []; + if (! is_array($hypervisors) || empty($hypervisors)) { + return 0; + } + + $hypMinSum = 0; + $ipv4CapForGroup = 0; + + foreach ($hypervisors as $h) { + $hyp = $h['hypervisor'] ?? []; + if (empty($hyp['enabled']) || empty($hyp['commissioned']) || ! empty($hyp['prohibit'])) { + continue; + } + + $res = $h['resources'] ?? []; + if (! is_array($res)) { + continue; + } + + $memCap = self::capFor($res['memory'] ?? null, (int) ($package['memory'] ?? 0), $bufferPct); + $cpuCap = self::capFor($res['cpuCores'] ?? null, (int) ($package['cpuCores'] ?? 0), $bufferPct); + $storeCap = self::capForStorage( + $res, + (int) ($package['primaryStorageProfile'] ?? 0), + (int) ($package['primaryStorage'] ?? 0), + $bufferPct, + ); + + $hypMinSum += min($memCap, $cpuCap, $storeCap); + + $ipv4Free = (int) ($res['network']['total']['ipv4']['free'] ?? 0); + if ($ipv4Free > 0) { + $ipv4Cap = intdiv($ipv4Free, max(1, $ipv4Required)); + if ($ipv4Cap > $ipv4CapForGroup) { + $ipv4CapForGroup = $ipv4Cap; + } + } + } + + // If no hypervisor reported any ipv4 data (unusual but defensible), don't let + // the cap kill an otherwise-valid count — treat as "no IPv4 constraint known". + if ($ipv4CapForGroup === 0) { + foreach ($hypervisors as $h) { + if (isset($h['resources']['network']['total']['ipv4']['free'])) { + // There WAS an ipv4 value (possibly 0); the cap is genuinely 0. + return 0; + } + } + + // No ipv4 data anywhere in the response → don't apply the cap. + return max(0, $hypMinSum); + } + + return min($hypMinSum, $ipv4CapForGroup); + } + + /** + * How many VPSes fit into a single (free, max, buffer) cell for one resource. + * + * Handles three edge cases consistent with live API behaviour: + * - need <= 0 → unlimited fit (nothing consumed for this dimension) + * - resource.max = 0 → unlimited quota; free can be negative but we don't care + * - negative/zero available after buffer → 0 (clamp; never negative qty) + */ + private static function capFor($resource, int $need, float $bufferPct): int + { + if ($need <= 0) { + return PHP_INT_MAX; + } + if (! is_array($resource)) { + return 0; + } + + $max = (int) ($resource['max'] ?? 0); + $free = (int) ($resource['free'] ?? 0); + + if ($max === 0) { + // Unlimited quota — buffer doesn't apply (X% of 0 is 0). + return PHP_INT_MAX; + } + + $reserve = (int) ceil(((float) $max) * ($bufferPct / 100.0)); + $available = $free - $reserve; + + if ($available <= 0) { + return 0; + } + + return intdiv($available, $need); + } + + /** + * Storage variant of capFor() that respects the package's primaryStorageProfile. + * + * Rules: + * - profileId > 0 → must match an otherStorage[].id on the hypervisor; if the + * matched pool is disabled or missing, this hypervisor has + * zero storage capacity for this product (can't place there). + * - profileId <= 0 → fall back to localStorage. If local is disabled, 0. + */ + private static function capForStorage(array $res, int $profileId, int $needGb, float $bufferPct): int + { + if ($needGb <= 0) { + return PHP_INT_MAX; + } + + if ($profileId > 0) { + foreach ($res['otherStorage'] ?? [] as $pool) { + if ((int) ($pool['id'] ?? 0) !== $profileId) { + continue; + } + if (empty($pool['enabled'])) { + return 0; + } + + return self::capFor( + ['max' => (int) ($pool['max'] ?? 0), 'free' => (int) ($pool['free'] ?? 0)], + $needGb, + $bufferPct, + ); + } + + // Storage profile not present on this hypervisor — cannot place the VM. + return 0; + } + + $local = $res['localStorage'] ?? null; + if (is_array($local) && ! empty($local['enabled'])) { + return self::capFor( + ['max' => (int) ($local['max'] ?? 0), 'free' => (int) ($local['free'] ?? 0)], + $needGb, + $bufferPct, + ); + } + + return 0; + } + + /** + * The admin-tunable safety buffer (configoption7), clamped to [0, 100]. + * + * Default is 10% when the field is blank or non-numeric — reserves 10% of each + * resource's max so we stop selling a product before the hypervisor is literally + * at 100%, which is where placement timing issues and fragmentation start biting. + * Admins can override per product (including down to 0) in the module settings. + */ + private function bufferPctForProduct($product): float + { + $raw = $product->configoption7 ?? ''; + if ($raw === null || $raw === '') { + return 10.0; + } + $val = is_numeric($raw) ? (float) $raw : 10.0; + + return max(0.0, min(100.0, $val)); + } + + // ----------------------------------------------------------------------- + // Hypervisor-group resolution + // ----------------------------------------------------------------------- + + /** + * Collect every hypervisor group ID this product could be provisioned into: + * the default (configoption1) plus every numeric value of the "Location" + * configurable option (if one is attached). + * + * @return int[] Deduplicated list of group IDs, strictly positive. + */ + private function resolveHypervisorGroupIds($product): array + { + $groups = []; + + $defaultGroup = (int) ($product->configoption1 ?? 0); + if ($defaultGroup > 0) { + $groups[] = $defaultGroup; + } + + $locationLabel = $this->optionLabelFor('hypervisorId'); + if ($locationLabel !== null && $locationLabel !== '') { + foreach ($this->fetchConfigurableOptionValues((int) $product->id, $locationLabel) as $value) { + $asInt = (int) $value; + if ($asInt > 0) { + $groups[] = $asInt; + } + } + } + + return array_values(array_unique($groups)); + } + + /** + * Look up every sub-option value for a given configurable option name on a product. + * + * WHMCS stores option names as either "Location" or "Location|Display Override" — + * this method normalises both by comparing just the part before the pipe. + * + * @return array Raw sub-option names (callers decide numeric parsing). + */ + private function fetchConfigurableOptionValues(int $productId, string $label): array + { + try { + $options = DB::table('tblproductconfiglinks as l') + ->join('tblproductconfigoptions as o', 'o.gid', '=', 'l.gid') + ->where('l.pid', $productId) + ->select('o.id', 'o.optionname') + ->get(); + + $matchedIds = []; + foreach ($options as $opt) { + $name = (string) $opt->optionname; + $pipe = strpos($name, '|'); + if ($pipe !== false) { + $name = substr($name, 0, $pipe); + } + if ($name === $label) { + $matchedIds[] = (int) $opt->id; + } + } + + if (empty($matchedIds)) { + return []; + } + + return DB::table('tblproductconfigoptionssub') + ->whereIn('configid', $matchedIds) + ->pluck('optionname') + ->toArray(); + } catch (\Throwable $e) { + Log::insert('StockControl:fetchConfigurableOptionValues', ['productId' => $productId, 'label' => $label], $e->getMessage()); + + return []; + } + } + + /** + * Resolve the WHMCS configurable-option label for an internal key, respecting + * config/ConfigOptionMapping.php overrides — same contract as ModuleFunctions::createAccount(). + */ + private function optionLabelFor(string $key): ?string + { + if ($this->optionLabelMap === null) { + $this->optionLabelMap = self::DEFAULT_OPTION_LABELS; + + try { + // Resolve the mapping file directly relative to this class — avoids + // depending on WHMCS's ROOTDIR, which isn't defined when the module + // is loaded outside a full WHMCS request (cron tooling, tests). + // __DIR__ is .../modules/servers/VirtFusionDirect/lib, so the config + // directory is one level up. + $overridePath = dirname(__DIR__) . '/config/ConfigOptionMapping.php'; + if (is_file($overridePath)) { + $override = require $overridePath; + if (is_array($override)) { + $this->optionLabelMap = array_merge($this->optionLabelMap, $override); + } + } + } catch (\Throwable $e) { + // Swallow — mapping override is best-effort; defaults still work. + } + } + + return $this->optionLabelMap[$key] ?? null; + } +}