Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fecbf701b7 | ||
|
|
02e059274b | ||
|
|
e9772ed29f | ||
|
|
a3c4154fb2 | ||
|
|
cece1f5ae0 | ||
|
|
f4d6b06203 | ||
|
|
1f09671fee | ||
|
|
6ae3ab55a9 | ||
|
|
0c913110cc |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,6 +2,42 @@
|
||||
|
||||
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
|
||||
- **Dynamic VPS stock control 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 real number of VPSes the panel can still provision and WHMCS handles the "Out of Stock" badge, Add-to-Cart gating, and checkout refusal natively — no template work required. qty is derived by combining two authoritative sources:
|
||||
- `GET /packages/{packageId}` for the per-VPS resource footprint (`memory`, `cpuCores`, `primaryStorage`, `primaryStorageProfile`, `enabled`)
|
||||
- `GET /compute/hypervisors/groups/{id}/resources` for live per-hypervisor free/allocated data
|
||||
|
||||
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. Confirmed-missing conditions (HTTP 404 on `/packages/{id}`, `package.enabled=false`) force qty=0; transient failures leave `qty` UNTOUCHED to avoid false out-of-stock during API blips.
|
||||
|
||||
- **Event-driven stock recalculation hooks:**
|
||||
- `AfterModuleCreate` — refreshes qty after every VirtFusion provision (capacity just decreased). Bursts of parallel provisions coalesce via a 30 s shared rate-limit.
|
||||
- `AfterModuleTerminate` — refreshes qty after every VirtFusion termination (capacity just increased). Shares the 30 s rate-limit with create.
|
||||
- `AfterCronJob` — every-2-hour safety net that catches capacity changes made directly in the VirtFusion panel without going through WHMCS. Interval tunable via `STOCK_CRON_INTERVAL_SECONDS` in `hooks.php`.
|
||||
- `ClientAreaPageCart` — opportunistic per-product refresh during the order flow, rate-limited to once per product per 60 s.
|
||||
|
||||
- **Order auto-accept after successful provision.** `AfterModuleCreate` calls WHMCS `AcceptOrder` (with `autosetup=false` so there's no double-provision) when the parent order is still in Pending status. Closes the gap for installs that rely on pending-order workflows for non-VF products but want VirtFusion provisions to auto-advance. Idempotent — already-accepted orders are skipped.
|
||||
|
||||
- **Admin-triggered full recalculation.** New `admin.php?action=stockRecalculate` action (POST + same-origin required) runs `StockControl::recalculateAll()` on demand and returns a JSON `{productId: qty}` map; the module log gets a compact summary (`{total, updated, zeroed, skipped}`) so it stays readable on stores with hundreds of products.
|
||||
|
||||
- **Per-product safety buffer.** New `stockSafetyBufferPct` config option (configoption7, default 10) reserves X% of each resource's `max` during stock calculation. Applied only to capped resources (unlimited resources with `max=0` skip the buffer). Admins can override per product in the module settings; blank falls back to 10% so existing products get sensible headroom without any config change.
|
||||
|
||||
- **Test Connection now probes `/compute/hypervisors/groups`.** A VirtFusion API token scoped only to `/servers` would pass the existing `/connect` check but silently break nightly stock updates. The admin's Test Connection button now surfaces missing `/compute` read scope at config time with a specific error rather than as unexplained nightly silence.
|
||||
|
||||
### Caching
|
||||
- New cache keys: `pkg:{packageId}` (10 min TTL, package definitions rarely change) and `grpres:{groupId}` (120 s TTL, resources change minute-to-minute under load). Confirmed 404 responses are cached for 60 s so an admin re-creating a deleted package/group takes effect quickly.
|
||||
|
||||
### Safety Properties
|
||||
- `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 transient API blips, or show inventory for deleted packages.
|
||||
- `\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.
|
||||
- Stock-control is gated by `tblproducts.stockcontrol=1` — products that opt out are never touched, even by the safety-net cron.
|
||||
|
||||
## [1.3.0] - 2026-04-17
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
34
CLAUDE.md
34
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 (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.
|
||||
|
||||
**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 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%.
|
||||
|
||||
**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
|
||||
|
||||
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
support@ezscale.cloud.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
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).
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
557
modules/servers/VirtFusionDirect/lib/StockControl.php
Normal file
557
modules/servers/VirtFusionDirect/lib/StockControl.php
Normal file
@@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
namespace WHMCS\Module\Server\VirtFusionDirect;
|
||||
|
||||
use WHMCS\Database\Capsule as DB;
|
||||
|
||||
/**
|
||||
* Computes accurate stock quantities for VirtFusionDirect products and writes them
|
||||
* to tblproducts.qty, leveraging WHMCS's native stock-control feature (badges,
|
||||
* disabled Add-to-Cart, checkout block) instead of building parallel UI.
|
||||
*
|
||||
* HOW THE NUMBER IS DERIVED
|
||||
* -------------------------
|
||||
* For every product with tblproducts.stockcontrol=1:
|
||||
*
|
||||
* qty = Σ groupCapacity(g, package, ipv4Req, bufferPct) for every eligible group g
|
||||
*
|
||||
* where groupCapacity is computed from live /compute/hypervisors/groups/{id}/resources
|
||||
* data and package is the VirtFusion /packages/{id} response — the authoritative
|
||||
* per-VPS resource footprint. Each hypervisor's per-metric capacity is
|
||||
* min(memory, cpu, storage), summed across hypervisors in the group; IPv4 is a
|
||||
* group-level pool so its cap is taken as the per-hypervisor max within the group
|
||||
* (not summed) to avoid double-counting.
|
||||
*
|
||||
* ELIGIBLE GROUPS
|
||||
* ---------------
|
||||
* The default group (tblproducts.configoption1) plus every value of the Location
|
||||
* configurable option, if the product exposes one. Location is detected by matching
|
||||
* the configurable option name against the "hypervisorId" label from
|
||||
* config/ConfigOptionMapping.php (falls back to "Location") — same convention
|
||||
* ModuleFunctions::createAccount() uses to map configoptions to VirtFusion fields.
|
||||
* This lets a single product span multiple regions and still get a meaningful qty.
|
||||
*
|
||||
* ELIGIBLE HYPERVISORS
|
||||
* --------------------
|
||||
* enabled=true AND commissioned=true AND prohibit=false. Everything else is skipped
|
||||
* with zero contribution to the group total.
|
||||
*
|
||||
* FAIL-SAFE INVARIANT
|
||||
* -------------------
|
||||
* CRITICAL: if the computation cannot complete (missing CP, transient API failure,
|
||||
* malformed response, no groups resolved), recalculateForProduct() returns null and
|
||||
* the caller MUST NOT touch tblproducts.qty. The reason: a false zero during a
|
||||
* transient failure would pull every product out of the storefront, causing
|
||||
* lost-order incidents that take human intervention to recover. Better to keep a
|
||||
* slightly-stale qty than to silently take the catalogue offline.
|
||||
*
|
||||
* Confirmed-missing cases (package 404 or package.enabled=false) DO return 0 —
|
||||
* that's the right answer, the product genuinely cannot be provisioned.
|
||||
*
|
||||
* CACHING
|
||||
* -------
|
||||
* Packages cached 10 min (rarely change), group resources cached 120 s (change
|
||||
* meaningfully minute-to-minute under load). Both handled inside Module's
|
||||
* fetchPackage / fetchGroupResources helpers, keyed 'pkg:{id}' / 'grpres:{id}' so
|
||||
* multiple products in a cron sweep share cached data for the same upstream call.
|
||||
*/
|
||||
class StockControl
|
||||
{
|
||||
/** Default mapping from internal VF key → WHMCS configurable-option label.
|
||||
* Kept in sync with $configOptionDefaultNaming in ModuleFunctions::createAccount(). */
|
||||
private const DEFAULT_OPTION_LABELS = [
|
||||
'ipv4' => '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<string,string>|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<int,int|null>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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:
|
||||
* - 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 $storageTypeId, int $needGb, float $bufferPct): int
|
||||
{
|
||||
if ($needGb <= 0) {
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
if ($storageTypeId > 0) {
|
||||
$best = 0;
|
||||
$matched = false;
|
||||
foreach ($res['otherStorage'] ?? [] as $pool) {
|
||||
if ((int) ($pool['storageType'] ?? 0) !== $storageTypeId) {
|
||||
continue;
|
||||
}
|
||||
$matched = true;
|
||||
if (empty($pool['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cap = self::capFor(
|
||||
['max' => (int) ($pool['max'] ?? 0), 'free' => (int) ($pool['free'] ?? 0)],
|
||||
$needGb,
|
||||
$bufferPct,
|
||||
);
|
||||
if ($cap > $best) {
|
||||
$best = $cap;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $matched) {
|
||||
// No pool of this storage type on this hypervisor — cannot place the VM.
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
$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<int,string> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user