Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fecbf701b7 | ||
|
|
02e059274b | ||
|
|
e9772ed29f | ||
|
|
a3c4154fb2 | ||
|
|
cece1f5ae0 |
@@ -2,6 +2,11 @@
|
||||
|
||||
All notable changes to the VirtFusion Direct Provisioning Module for WHMCS.
|
||||
|
||||
## [1.4.1] - 2026-04-25
|
||||
|
||||
### Bug Fixes
|
||||
- **Critical: stock control returned qty=0 fleet-wide for packages with a `primaryStorageProfile`.** `StockControl::capForStorage()` was comparing the package's `primaryStorageProfile` against `otherStorage[].id`, but the VirtFusion API exposes that field as a **storage type code** (mirrors `server_packages.storage_type`) — a filter that should match `otherStorage[].storageType`. Pool ids are unique per hypervisor (e.g. 23/28/30 for the same logical mountpoint on three nodes) and almost never collide with the type-code domain (0=local, 4=mountpoint, etc.), so the check returned 0 for every hypervisor and silently zeroed inventory for any product that opted into stock control with a non-default storage profile. Symptoms: every stock-controlled VPS product showed qty=0 in WHMCS despite abundant memory/CPU/IPv4 capacity; only workarounds were disabling stock control or removing `primaryStorageProfile` from the package, both of which defeat the gating. Fix: match `pool.storageType` instead of `pool.id`; walk all pools that match (a hypervisor may carry multiple pools of the same type) and pick the one that fits the most VMs; treat a disabled pool as skip-and-continue rather than a hard zero, so an enabled peer of the same type still contributes. Also renamed the internal `$profileId` parameter to `$storageTypeId` so future readers don't fall into the same naming trap. Verified on a 3-hypervisor cluster: qty went from 0/0/0/0/0/0/0/0 to 66/32/15/7/3/1/32/15 across the VPS-1 through VPS-32 products with no other config change.
|
||||
|
||||
## [1.4.0] - 2026-04-24
|
||||
|
||||
### Features
|
||||
|
||||
@@ -141,7 +141,7 @@ Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockco
|
||||
|
||||
**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
|
||||
- `GET /compute/hypervisors/groups/{id}/resources` — live free/allocated per hypervisor with per-metric quotas, storage pools (filtered by `pool.storageType` against the package's `primaryStorageProfile` *type code* — see Safety properties), 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.
|
||||
|
||||
@@ -160,7 +160,7 @@ Opt-in per product via WHMCS's native stock-control toggle (`tblproducts.stockco
|
||||
- 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.
|
||||
- Storage matching uses the package's `primaryStorageProfile` as a **storage type code** (it mirrors VirtFusion's `server_packages.storage_type` column — a *filter*, not a pool id). The hypervisor must expose at least one `otherStorage[]` pool whose `storageType` equals that code; if multiple match (e.g. several mountpoint pools on the same hypervisor) the one that fits the most VMs wins. A disabled pool is skipped, not fatal — an enabled peer of the same type still contributes. Hypervisors with no pool of the matching type contribute 0. Falls back to `localStorage` only when the package has no profile set (`primaryStorageProfile <= 0`).
|
||||
- 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%.
|
||||
|
||||
62
README.md
62
README.md
@@ -20,6 +20,7 @@ A comprehensive WHMCS provisioning module for [VirtFusion](https://virtfusion.co
|
||||
- [Module Configuration Options](#module-configuration-options)
|
||||
- [Configurable Options (Dynamic Pricing)](#configurable-options-dynamic-pricing)
|
||||
- [Custom Option Name Mapping](#custom-option-name-mapping)
|
||||
- [Stock Control (Dynamic Inventory)](#stock-control-dynamic-inventory)
|
||||
- [Reverse DNS Addon (PowerDNS)](#reverse-dns-addon-powerdns)
|
||||
- [Client Area Features](#client-area-features)
|
||||
- [Admin Area Features](#admin-area-features)
|
||||
@@ -86,6 +87,15 @@ You also need a VirtFusion API token with the following permissions:
|
||||
- Checkout validation ensuring OS selection before order placement
|
||||
- **Resource sliders** - Configurable option dropdowns are replaced with interactive range sliders
|
||||
- Compatible with all WHMCS order form templates
|
||||
- **Order auto-accept after provision** — when a paid order's VirtFusion service provisions successfully, the module calls WHMCS `AcceptOrder` (with `autosetup=false` so there's no double-provision) to flip the order from Pending → Active automatically. Idempotent; already-accepted orders are untouched.
|
||||
|
||||
### Stock Control (Dynamic Inventory)
|
||||
- **Out-of-stock badges driven by real hypervisor capacity** — opt-in per product via WHMCS's native Stock Control toggle. When enabled, the module keeps `tblproducts.qty` synced to the number of VPSes the panel can still actually provision, and WHMCS renders the "Out of Stock" badge, disables Add-to-Cart, and refuses checkout natively. No templates or JavaScript required.
|
||||
- **Live-capacity math** — combines `/packages/{id}` (per-VPS resource footprint) with `/compute/hypervisors/groups/{id}/resources` (live per-hypervisor free/allocated) to compute qty across every group the product can be placed in. Storage matching is by **type code** (`pool.storageType`), so a package targeting e.g. mountpoint storage qualifies on every hypervisor that exposes a mountpoint pool — and picks the largest-fit pool when several share the same type. Group-level IPv4 pool accounted for without double-counting.
|
||||
- **Event-driven refresh** — qty recalculates after every successful provision (`AfterModuleCreate`), termination (`AfterModuleTerminate`), and on cart/order page views for individual products. A 2-hour safety-net cron catches capacity changes made directly in the VirtFusion panel.
|
||||
- **Per-product safety buffer** — `stockSafetyBufferPct` config option (default 10%) reserves headroom so the storefront stops selling before a hypervisor is literally at 100%.
|
||||
- **Fail-safe under API outages** — transient VirtFusion API failures leave `qty` UNCHANGED instead of zeroing it, so a brief network blip doesn't take the catalogue offline.
|
||||
- **Admin recalc on demand** — POST `admin.php?action=stockRecalculate` forces a full re-sweep.
|
||||
|
||||
### Usage Tracking
|
||||
- **Automated bandwidth sync** - WHMCS daily cron pulls traffic usage from VirtFusion
|
||||
@@ -183,7 +193,7 @@ The fields are hidden text boxes that are dynamically replaced by dropdown selec
|
||||
|
||||
### Module Configuration Options
|
||||
|
||||
Each product has three module-specific settings:
|
||||
Each product has these module-specific settings:
|
||||
|
||||
| Option | Name | Description | Default |
|
||||
|---|---|---|---|
|
||||
@@ -193,6 +203,7 @@ Each product has three module-specific settings:
|
||||
| Config Option 4 | Self-Service Mode | Enable VirtFusion self-service billing (0=Disabled, 1=Hourly, 2=Resource Packs, 3=Both) | 0 |
|
||||
| Config Option 5 | Auto Top-Off Threshold | Credit balance below which auto top-off triggers during cron (0=disabled) | 0 |
|
||||
| Config Option 6 | Auto Top-Off Amount | Credit amount to add when auto top-off triggers | 100 |
|
||||
| Config Option 7 | Stock Safety Buffer (%) | Headroom reserved per resource during stock calculation (0-100). Only effective with WHMCS Stock Control enabled on the product; blank falls back to the default. | 10 |
|
||||
|
||||
You can find your Hypervisor Group IDs and Package IDs in the VirtFusion admin panel.
|
||||
|
||||
@@ -230,6 +241,55 @@ return [
|
||||
];
|
||||
```
|
||||
|
||||
### Stock Control (Dynamic Inventory)
|
||||
|
||||
Optional but recommended once the catalogue is backed by real hypervisor capacity. When enabled on a product, the module keeps `tblproducts.qty` synced with the number of VPSes the panel can still actually provision — then WHMCS renders "Out of Stock" badges, disables Add-to-Cart, and refuses checkout entirely on its own.
|
||||
|
||||
**Prerequisites:**
|
||||
- The VirtFusion API token on the WHMCS server must have read access to both `/packages` and `/compute/hypervisors/groups`. The **Test Connection** button (Admin → System Settings → Servers) now probes the compute endpoint explicitly — if the token is missing that scope you'll see a clear error at config time instead of nightly silence.
|
||||
- No addon to activate. Stock control is enabled per product via WHMCS's native toggle.
|
||||
|
||||
**Enabling it on a product:**
|
||||
|
||||
1. WHMCS Admin → **System Settings → Products/Services → Products/Services** → edit the product.
|
||||
2. Under the **Details** tab, tick **Stock Control** and save. (Leave *Quantity* at 0 — the module will populate it on the next recalc.)
|
||||
3. Optionally tune **Config Option 7 — Stock Safety Buffer (%)** in the **Module Settings** tab. Default 10% means the module reserves 10% of each resource's max before counting fits, so you stop selling before a hypervisor is at 100%. Set to 0 for no buffer, higher for more headroom.
|
||||
4. Either wait for the next recalc event (within 2 hours) or force one immediately: POST to `modules/servers/VirtFusionDirect/admin.php?action=stockRecalculate` from an authenticated admin session.
|
||||
|
||||
**How qty is computed:**
|
||||
|
||||
For every stock-controlled VirtFusion product:
|
||||
|
||||
1. Resolve the set of hypervisor groups the product can be placed in — the default group (Config Option 1) plus every numeric value of the `Location` configurable option if one is attached.
|
||||
2. Fetch the product's package via `GET /packages/{id}` for the per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`).
|
||||
3. For each eligible group, fetch live resources via `GET /compute/hypervisors/groups/{id}/resources`.
|
||||
4. For each hypervisor in the group that passes eligibility (`enabled` AND `commissioned` AND `!prohibit`), compute `min(memory, cpu, storage)` fits — with the per-product buffer applied — against the matched storage pool. `package.primaryStorageProfile` is a **storage type code** (mirrors VirtFusion's `server_packages.storage_type` column — a *filter*, not a pool id), matched against each `otherStorage[].storageType`. If multiple pools on the same hypervisor share that type (e.g. several mountpoint pools), the one with the largest fit wins; disabled peers are skipped, not fatal. Falls back to `localStorage` only when the package has no profile set.
|
||||
5. Sum across hypervisors in each group, cap by the group-level IPv4 pool (`max()` within a group to avoid double-counting the shared pool), then sum across groups → `qty`.
|
||||
|
||||
**Refresh triggers:**
|
||||
|
||||
| Event | Trigger | Rate limit |
|
||||
|---|---|---|
|
||||
| New provision | `AfterModuleCreate` hook | 30 s shared with termination |
|
||||
| VPS termination | `AfterModuleTerminate` hook | 30 s shared with create |
|
||||
| Cart / order page view | `ClientAreaPageCart` hook | 60 s per product |
|
||||
| Out-of-band panel change safety net | `AfterCronJob` hook | 2 hours (tunable via `STOCK_CRON_INTERVAL_SECONDS` in `hooks.php`) |
|
||||
| Admin manual recalc | `admin.php?action=stockRecalculate` (POST + same-origin) | On demand |
|
||||
|
||||
**Safety properties:**
|
||||
- **Transient API failures leave `qty` UNCHANGED.** `Module::fetchPackage()` and `Module::fetchGroupResources()` return a tri-state `array | false | null`: `false` means "VirtFusion confirmed this doesn't exist → OOS is correct", `null` means "we can't tell right now → don't touch existing qty". Without this distinction the module would either zero out inventory during API blips or show inventory for deleted packages.
|
||||
- **Confirmed-missing → qty=0.** HTTP 404 on the package or `package.enabled=false` forces qty=0, because the product genuinely cannot be provisioned.
|
||||
- **Storage type mismatch → 0 for that hypervisor.** If the package targets storage type code `4` (mountpoint) but the hypervisor only exposes pools of type `0` (local default), that hypervisor contributes zero capacity — not a guess at "maybe placement will work out." This is a filter on `pool.storageType`, not on `pool.id`; identical type codes across different hypervisors all qualify, which is what makes multi-hypervisor mountpoint/datastore placement work.
|
||||
- **Stock Control gate is absolute.** Products without `tblproducts.stockcontrol=1` are never touched, even by the cron safety net.
|
||||
- **`\Throwable` catches** on every stock-path entry point (not just `\Exception`) so a `TypeError` from a malformed API response can't escape the tri-state contract.
|
||||
|
||||
**Caching:**
|
||||
- `pkg:{packageId}` — 10 min TTL (package definitions rarely change)
|
||||
- `grpres:{groupId}` — 120 s TTL (resources change minute-to-minute under load; shared across products that target the same group)
|
||||
- Confirmed 404 responses cached 60 s so re-creating a deleted package/group takes effect quickly.
|
||||
|
||||
**Order auto-accept:** the `AfterModuleCreate` hook additionally calls WHMCS `AcceptOrder` with `autosetup=false` when the service's parent order is still in Pending status. This closes the loop for installs that rely on a pending-order workflow for non-VF products but want VirtFusion provisions to advance to Active automatically. Idempotent — already-accepted orders are skipped.
|
||||
|
||||
### Reverse DNS Addon (PowerDNS)
|
||||
|
||||
Optional. Activate the `VirtFusionDns` addon module to let the provisioning module manage PTR records in a PowerDNS instance automatically (and expose an rDNS editor to clients).
|
||||
|
||||
@@ -365,36 +365,56 @@ class StockControl
|
||||
/**
|
||||
* Storage variant of capFor() that respects the package's primaryStorageProfile.
|
||||
*
|
||||
* NOTE on naming: VirtFusion exposes two confusingly-named fields with the
|
||||
* same numeric domain. `package.primaryStorageProfile` (mirrors the DB column
|
||||
* `server_packages.storage_type`) is a **storage type code** — a filter,
|
||||
* not an ID — and matches `otherStorage[].storageType` on each hypervisor.
|
||||
* The pool's own `id` is unique per hypervisor and is never what the package
|
||||
* targets. Treating $storageTypeId as `pool.id` (as this method previously
|
||||
* did) returned 0 for every package whose type code didn't happen to also
|
||||
* exist as a pool id, silently zeroing qty fleet-wide.
|
||||
*
|
||||
* 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.
|
||||
* - storageTypeId > 0 → match any enabled otherStorage[] whose storageType
|
||||
* equals this code. If multiple match (e.g. several
|
||||
* mountpoint pools on one hypervisor), pick the one
|
||||
* that fits the most VMs.
|
||||
* - storageTypeId <= 0 → fall back to localStorage. If local is disabled, 0.
|
||||
*/
|
||||
private static function capForStorage(array $res, int $profileId, int $needGb, float $bufferPct): int
|
||||
private static function capForStorage(array $res, int $storageTypeId, int $needGb, float $bufferPct): int
|
||||
{
|
||||
if ($needGb <= 0) {
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
if ($profileId > 0) {
|
||||
if ($storageTypeId > 0) {
|
||||
$best = 0;
|
||||
$matched = false;
|
||||
foreach ($res['otherStorage'] ?? [] as $pool) {
|
||||
if ((int) ($pool['id'] ?? 0) !== $profileId) {
|
||||
if ((int) ($pool['storageType'] ?? 0) !== $storageTypeId) {
|
||||
continue;
|
||||
}
|
||||
$matched = true;
|
||||
if (empty($pool['enabled'])) {
|
||||
return 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
return self::capFor(
|
||||
$cap = self::capFor(
|
||||
['max' => (int) ($pool['max'] ?? 0), 'free' => (int) ($pool['free'] ?? 0)],
|
||||
$needGb,
|
||||
$bufferPct,
|
||||
);
|
||||
if ($cap > $best) {
|
||||
$best = $cap;
|
||||
}
|
||||
}
|
||||
|
||||
// Storage profile not present on this hypervisor — cannot place the VM.
|
||||
return 0;
|
||||
if (! $matched) {
|
||||
// No pool of this storage type on this hypervisor — cannot place the VM.
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
$local = $res['localStorage'] ?? null;
|
||||
|
||||
Reference in New Issue
Block a user