From 1f09671fee41e0893d32f52db5d20a87f57e05a4 Mon Sep 17 00:00:00 2001 From: Prophet731 Date: Fri, 24 Apr 2026 12:09:57 -0400 Subject: [PATCH] feat(stock): dynamic VPS inventory driven by live hypervisor capacity Opt-in per product via WHMCS's native tblproducts.stockcontrol toggle. When enabled, the module overwrites tblproducts.qty with the number of VPSes the panel can still actually provision, derived from two authoritative sources: - GET /packages/{id} for the per-VPS resource footprint (memory, cpuCores, primaryStorage, primaryStorageProfile, enabled) - GET /compute/hypervisors/groups/{id}/resources for live free/allocated data per hypervisor in the group Algorithm sums min(memory, cpu, storage) across eligible hypervisors (enabled AND commissioned AND !prohibit) for every group the product can be placed in (default configoption1 plus every numeric value of a Location configurable option), capped by the group-level IPv4 pool taken as max() within a group to avoid double-counting. Storage matching is strict against package.primaryStorageProfile; hypervisors without the named pool contribute 0. FAIL-SAFE INVARIANT: transient API failures return null from Module::fetchPackage / Module::fetchGroupResources, and the orchestrator leaves tblproducts.qty UNCHANGED in that case. Confirmed-missing conditions (HTTP 404, package.enabled=false) return qty=0. Without this tri-state contract the module would either zero out inventory during API blips, or show inventory for packages that have been deleted. Triggers: - AfterModuleCreate: refresh + auto-accept pending order - AfterModuleTerminate: refresh (capacity came back) - AfterCronJob: every-2-hour safety net for out-of-band panel changes - ClientAreaPageCart: opportunistic per-product refresh in order flow - admin.php?action=stockRecalculate: on-demand full recalc Shared 30s rate-limit (stockrefresh:event) coalesces provision bursts; 60s per-product limit (stockrefresh:{pid}) caps cart-page refreshes; grpres:{id} 120s TTL caps upstream API reads per group regardless of how often hooks fire. Auto-accept: AfterModuleCreate calls WHMCS AcceptOrder with autosetup=false when the parent order is still Pending. Idempotent; already-accepted orders are skipped via strcasecmp status check. New per-product config option stockSafetyBufferPct (configoption7, default 10) reserves X% of each resource's max before computing fits. Blank falls back to 10% so existing products get headroom without any config change. Ignored for unlimited resources (max=0) and for IPv4 (no per-hypervisor max in the response). TestConnection now probes /compute/hypervisors/groups to surface missing compute:read scope at config time instead of as unexplained nightly silence. --- CLAUDE.md | 34 ++ .../VirtFusionDirect/VirtFusionDirect.php | 29 + modules/servers/VirtFusionDirect/admin.php | 41 ++ modules/servers/VirtFusionDirect/hooks.php | 191 +++++++ .../servers/VirtFusionDirect/lib/Module.php | 179 ++++++ .../VirtFusionDirect/lib/StockControl.php | 537 ++++++++++++++++++ 6 files changed, 1011 insertions(+) create mode 100644 modules/servers/VirtFusionDirect/lib/StockControl.php 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; + } +}