diff --git a/CLAUDE.md b/CLAUDE.md index 7bec4cb..1f593e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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%. diff --git a/README.md b/README.md index 4a193d8..9c1e5ff 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ You also need a VirtFusion API token with the following permissions: ### 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. Strict storage-profile matching; group-level IPv4 pool accounted for without double-counting. +- **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. @@ -263,7 +263,7 @@ 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 (strict match on `package.primaryStorageProfile` against `otherStorage[].id`; falls back to `localStorage` only when the package has no profile set). +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:** @@ -279,7 +279,7 @@ For every stock-controlled VirtFusion product: **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 profile mismatch → 0 for that hypervisor.** If the package targets `primaryStorageProfile=4` but the hypervisor only exposes profiles 23/28, that hypervisor contributes zero capacity — not a guess at "maybe placement will work out." +- **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.