13 Commits

Author SHA1 Message Date
ff0229061d Add checksum file for v0.6.1 release
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:24:32 +01:00
7bbffa50b4 Release v0.6.1 - UI improvements and bug fixes
- Fix admin license test popup showing empty product field
- Display product name in bold in test license modal
- Split auto-update settings into notification and auto-install options
- Add filter functionality to customer account licenses page
- Update translations (402 strings)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:22:45 +01:00
e168b1a44b Update translations for v0.6.1
- Regenerated .pot template with current strings
- All 402 strings translated in German (de_CH)
- Compiled .mo binary file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:21:29 +01:00
eb8818aa81 Update CLAUDE.md with v0.6.0 session history
- Document WordPress auto-update system implementation
- Add /update-check endpoint to REST API table
- Add Update/ directory to project structure
- Add Email/ directory to project structure
- Update temporary roadmap to v0.7.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:17:20 +01:00
fddeda4a80 Add checksum file for v0.6.0 release
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:15:29 +01:00
b670bacf27 Add WordPress auto-update functionality (v0.6.0)
- Add UpdateController REST API endpoint for serving update info to licensed plugins
- Add PluginUpdateChecker singleton for client-side update checking
- Hook into WordPress native plugin update system (pre_set_site_transient_update_plugins, plugins_api)
- Add Auto-Updates settings subtab with enable/disable and check frequency options
- Add authentication headers for secure download requests
- Support configurable cache TTL for update checks (default 12 hours)
- Document /update-check endpoint in OpenAPI specification
- Update German translations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:14:11 +01:00
f8f6434342 Update CLAUDE.md with v0.5.14 and v0.5.15 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:48:36 +01:00
dace416608 Add checksum file for v0.5.15 release
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:43:36 +01:00
72017f4c62 Fix tab rendering bug in WooCommerce product edit page (v0.5.15)
- Simplified JavaScript to avoid conflicts with WooCommerce's native show/hide logic
- Removed conflicting CSS rule for .hide_if_licensed
- License Settings tab uses CSS class toggle for proper display
- Variations tab properly shows for licensed-variable via woocommerce_product_data_tabs filter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:39:55 +01:00
f9efe698ea Fix Product Versions meta box not appearing for licensed-variable products (v0.5.14)
- Product Versions meta box now always added to product pages, visibility controlled via CSS/JavaScript
- Added Installer::registerProductTypes() to create product type terms in the product_type taxonomy
- Product type terms are now ensured to exist on woocommerce_init hook for existing installations
- Fixed License Settings tab and Product Versions visibility toggling when changing product types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:00:34 +01:00
d2e3b41a00 Add checksum file for v0.5.13 release
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 15:47:00 +01:00
4b6fafe500 Update CLAUDE.md with v0.5.12 and v0.5.13 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 15:46:12 +01:00
d29697ac62 Fix licenses not showing in admin order form for variable products (v0.5.13)
- Fix OrderLicenseController to use isLicensedProduct() for consistent product type detection
- Fixed expected licenses calculation for variable product orders
- Fixed manual license generation from admin order page for variable products
- Remove debug logging from all source files (PHP and JavaScript)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 15:45:32 +01:00
31 changed files with 3320 additions and 1330 deletions

View File

@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.6.1] - 2026-01-27
### Added
- Filter functionality on customer account licenses page (filter by product or domain)
- Split auto-update settings into two options: "Enable Update Notifications" and "Automatically Install Updates"
- New `isUpdateNotificationEnabled()`, `isAutoInstallEnabled()` static methods in SettingsController
- WordPress auto-update filter integration (`auto_update_plugin`) for automatic installation
### Fixed
- Fixed admin license test popup showing empty product field
- `handleAjaxTestLicense()` now enriches response with product name
- Removed version field from test popup (version_id is only set for version-bound licenses)
### Changed
- Updated `magdev/wc-licensed-product-client` dependency to v0.2.1
- "Automatically Install Updates" is only selectable when "Enable Update Notifications" is enabled
## [0.6.0] - 2026-01-27
### Added
- WordPress-style automatic update system for licensed plugins
- Server-side `/update-check` REST API endpoint for WordPress-compatible update information
- Client-side `PluginUpdateChecker` singleton for WordPress update integration
- New "Auto-Updates" settings subtab with enable/disable and check frequency options
- Secure download authentication via `X-License-Key` header
- Response signing support for tamper-proof update responses
- Configurable cache TTL for update checks (1-168 hours)
### Changed
- Updated OpenAPI specification to version 0.6.0 with `/update-check` endpoint documentation
## [0.5.15] - 2026-01-27
### Fixed
- Fixed tab rendering bug in WooCommerce product edit page when switching to licensed or licensed-variable product types
- Simplified JavaScript to avoid conflicts with WooCommerce's native show/hide logic
- Removed conflicting CSS rule for `.hide_if_licensed` that was causing layout issues
- License Settings tab now uses CSS class toggle (`.wclp-active`) instead of jQuery `.show()/.hide()` for proper display
- Variations tab now properly shows for licensed-variable products via `woocommerce_product_data_tabs` filter
## [0.5.14] - 2026-01-27
### Fixed
- **CRITICAL:** Fixed Product Versions meta box not appearing for licensed-variable products
- Product Versions meta box now always added to product pages, visibility controlled via CSS/JavaScript
- Added `Installer::registerProductTypes()` to create product type terms in the `product_type` taxonomy
- Product type terms are now ensured to exist on `woocommerce_init` hook for existing installations
- Fixed License Settings tab and Product Versions visibility toggling when changing product types
## [0.5.13] - 2026-01-27
### Fixed
- **CRITICAL:** Fixed licenses not showing in admin order form for licensed-variable products
- `OrderLicenseController` now uses `LicenseManager::isLicensedProduct()` for consistent product type detection
- Fixed expected licenses calculation for variable product orders
- Fixed manual license generation from admin order page for variable products
### Changed
- Removed debug logging from all source files (PHP and JavaScript)
- Cleaned up checkout blocks integration, Store API extension, and checkout controller
## [0.5.12] - 2026-01-27 ## [0.5.12] - 2026-01-27
### Fixed ### Fixed

257
CLAUDE.md
View File

@@ -32,9 +32,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. **Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
### Version 0.6.0 ### Version 0.7.0
*No planned features yet.* No changes planned at the moment
## Technical Stack ## Technical Stack
@@ -197,11 +197,13 @@ wc-licensed-product/
├── releases/ # Release packages (version 0.1.0+) ├── releases/ # Release packages (version 0.1.0+)
├── src/ ├── src/
│ ├── Admin/ # AdminController - license management UI │ ├── Admin/ # AdminController - license management UI
│ ├── Api/ # RestApiController - license validation endpoints │ ├── Api/ # RestApiController, UpdateController - REST API endpoints
│ ├── Checkout/ # CheckoutController - domain field at checkout │ ├── Checkout/ # CheckoutController - domain field at checkout
│ ├── Email/ # WooCommerce email classes
│ ├── Frontend/ # AccountController - customer licenses page │ ├── Frontend/ # AccountController - customer licenses page
│ ├── License/ # License model and LicenseManager │ ├── License/ # License model and LicenseManager
── Product/ # LicensedProduct type and LicensedProductType ── Product/ # LicensedProduct type and LicensedProductType
│ └── Update/ # PluginUpdateChecker - WordPress auto-update integration
├── templates/ ├── templates/
│ ├── admin/ # Twig templates for admin views │ ├── admin/ # Twig templates for admin views
│ └── frontend/ # Twig templates for customer views │ └── frontend/ # Twig templates for customer views
@@ -219,11 +221,12 @@ Created on plugin activation via `Installer::createTables()`:
Base: `/wp-json/wc-licensed-product/v1/` Base: `/wp-json/wc-licensed-product/v1/`
| Endpoint | Method | Description | | Endpoint | Method | Description |
| ----------- | ------ | ------------------------------- | | --------------- | ------ | ---------------------------------- |
| `/validate` | POST | Validate license key for domain | | `/validate` | POST | Validate license key for domain |
| `/status` | POST | Get license status | | `/status` | POST | Get license status |
| `/activate` | POST | Activate license on domain | | `/activate` | POST | Activate license on domain |
| `/update-check` | POST | Check for plugin updates (v0.6.0+) |
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification). Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
@@ -1543,3 +1546,239 @@ Series of bug fixes for licensed variable products that were showing frontend er
- Created release package: `releases/wc-licensed-product-0.5.11.zip` (857 KB) - Created release package: `releases/wc-licensed-product-0.5.11.zip` (857 KB)
- SHA256: `32571178bfa8f0d0a03ed05b498d5f9b3c860104393a96732e86a03b6de298d2` - SHA256: `32571178bfa8f0d0a03ed05b498d5f9b3c860104393a96732e86a03b6de298d2`
- Committed to `dev` branch - Committed to `dev` branch
### 2026-01-27 - Version 0.5.12 - Stock Display Fix
**Overview:**
Fixed stock indicator appearing in cart for licensed variable product variations.
**Bug Fix:**
- Fixed "1 in stock" message appearing in cart for licensed variable product variations
- Added multiple WooCommerce filter overrides to suppress stock display
**Modified files:**
- `src/Product/LicensedVariableProduct.php` - Override `get_children()`, `get_variation_attributes()`, `get_variation_prices()`, `get_available_variations()`, `is_type()`
- `src/Product/LicensedProductVariation.php` - Added `get_availability()`, `managing_stock()`, `is_purchasable()` overrides
- `src/Product/LicensedProductType.php` - Added stock-related filter hooks
**Technical notes:**
- `get_children()` uses direct SQL query to bypass WooCommerce's `is_type('variable')` check
- `is_type()` override returns true for both 'licensed-variable' and 'variable' type checks
- Stock filters: `woocommerce_get_availability_text`, `woocommerce_product_get_stock_quantity`, `woocommerce_product_variation_get_stock_quantity`
### 2026-01-27 - Version 0.5.13 - Admin Order License Display Fix
**Overview:**
Fixed licenses not showing in admin order form for licensed-variable products and removed debug logging.
**Bug Fixes:**
- **CRITICAL:** Fixed licenses not appearing in admin order form for orders containing licensed-variable products
- `OrderLicenseController` now uses `LicenseManager::isLicensedProduct()` for consistent product type detection across 4 locations
- Fixed expected licenses calculation for variable product orders
- Fixed manual license generation from admin order page for variable products
**Cleanup:**
- Removed all debug `error_log()` calls from PHP source files
- Removed all debug `console.log()` calls from JavaScript files
- Files cleaned: Plugin.php, CheckoutBlocksIntegration.php, StoreApiExtension.php, CheckoutController.php, checkout-blocks.js
**Modified files:**
- `src/Admin/OrderLicenseController.php` - Use `isLicensedProduct()` in 4 locations
- `src/Plugin.php` - Remove debug logging
- `src/Checkout/CheckoutBlocksIntegration.php` - Remove debug logging
- `src/Checkout/StoreApiExtension.php` - Remove debug logging
- `src/Checkout/CheckoutController.php` - Remove debug logging
- `assets/js/checkout-blocks.js` - Remove debug logging
**Release v0.5.13:**
- Created release package: `releases/wc-licensed-product-0.5.13.zip` (1.0 MB)
- SHA256: `814710ad899529d0015494e4b332eace7d8e55aeda381fdf61f99274c0bf910c`
- Committed to `dev` branch
### 2026-01-27 - Version 0.5.14 - Product Versions Meta Box Fix
**Overview:**
Fixed Product Versions meta box not appearing for licensed-variable products in admin.
**Bug Fixes:**
- Product Versions meta box now always added to product pages, visibility controlled via CSS/JavaScript
- Added `Installer::registerProductTypes()` to create product type terms in the `product_type` taxonomy
- Product type terms are now ensured to exist on `woocommerce_init` hook for existing installations
**Modified files:**
- `src/Admin/VersionAdminController.php` - Simplified `addVersionsMetaBox()` to always add meta box
- `src/Installer.php` - Added `registerProductTypes()` method
- `src/Product/LicensedProductType.php` - Added `ensureProductTypeTermsExist()` hook
**Technical notes:**
- WooCommerce's `WC_Product_Factory::get_product_type()` requires product type terms to exist in the `product_type` taxonomy
- Meta box visibility is controlled via JavaScript based on selected product type
- Taxonomy terms are registered on `woocommerce_init` hook to ensure WooCommerce is fully loaded
### 2026-01-27 - Version 0.5.15 - Tab Rendering Fix
**Overview:**
Fixed tab rendering bug in WooCommerce product edit page when switching to licensed or licensed-variable product types.
**Bug Fixes:**
- Fixed tab rendering issue where License Settings and Variations tabs appeared shifted/overlapping
- Simplified JavaScript to avoid conflicts with WooCommerce's native show/hide logic
- Removed conflicting CSS rule for `.hide_if_licensed` that was causing layout issues
- License Settings tab now uses CSS class toggle (`.wclp-active`) instead of jQuery `.show()/.hide()`
- Variations tab properly shows for licensed-variable products via `woocommerce_product_data_tabs` filter
**Modified files:**
- `src/Product/LicensedProductType.php` - Simplified `toggleOurElements()` JavaScript function, added `show_if_licensed-variable` class to variations tab
- `assets/css/admin.css` - Removed `.hide_if_licensed` rule, updated tab visibility CSS to target `li.licensed_product_options`
**Technical notes:**
- jQuery's `.show()` sets `display: block` which can break `<li>` element layouts in tab lists
- Using CSS class toggle (`addClass/removeClass`) preserves proper display values
- WooCommerce product data tabs use class pattern `{tab_key}_options` (e.g., `licensed_product_options`)
- The `woocommerce_product_data_tabs` filter allows adding classes to existing tabs like variations
**Release v0.5.15:**
- Created release package: `releases/wc-licensed-product-0.5.15.zip` (862 KB)
- SHA256: `47407de49bae4c649644af64e87b44b32fb30eeb2d50890ff8c4bbb741059278`
- Committed to `dev` branch
### 2026-01-27 - Version 0.6.0 - WordPress Auto-Update System
**Overview:**
Major feature release implementing WordPress-style automatic updates. Licensed plugins can now receive updates through WordPress's native plugin update mechanism by checking against the license server.
**New files:**
- `src/Api/UpdateController.php` - Server-side REST API endpoint for update checks
- `src/Update/PluginUpdateChecker.php` - Client-side singleton for WordPress update integration
**Implemented:**
- Server-side `/update-check` REST API endpoint serving WordPress-compatible update information
- Client-side `PluginUpdateChecker` singleton hooking into WordPress's native update system
- Hooks: `pre_set_site_transient_update_plugins`, `plugins_api`, `http_request_args`
- New "Auto-Updates" settings subtab with enable/disable toggle and check frequency
- Configurable cache TTL for update checks (1-168 hours, default: 12)
- Secure download authentication via `X-License-Key` header
- Response signing support for tamper-proof update responses
**Modified files:**
- `src/Plugin.php` - Added UpdateController and PluginUpdateChecker initialization
- `src/Admin/SettingsController.php` - Added 'auto-updates' section with settings
- `openapi.json` - Documented `/update-check` endpoint with request/response schemas
- `languages/*` - Updated translations for new strings
**Settings Controller Changes:**
- Added `'auto-updates'` to `getSections()` for sub-tab navigation
- New `getAutoUpdatesSettings()` method returning enable/frequency settings
- New static methods: `isAutoUpdateEnabled()`, `getUpdateCheckFrequency()`
**UpdateController API:**
- Endpoint: `POST /wp-json/wc-licensed-product/v1/update-check`
- Request: `license_key`, `domain`, `plugin_slug` (optional), `current_version` (optional)
- Response: `update_available`, `version`, `download_url`, `package`, `changelog`, `tested`, `requires`, `requires_php`, etc.
- License validation before serving update info
- Secure download URL generation using existing DownloadController patterns
**PluginUpdateChecker Features:**
- Singleton pattern with `getInstance()`
- Caching via WordPress transients (`wclp_update_info`)
- Automatic cache clearing on settings save
- Only activates when license server URL is configured and not self-licensing
- `forceUpdateCheck()` method for manual refresh
**Configuration:**
To disable auto-updates programmatically:
```php
define('WC_LICENSE_DISABLE_AUTO_UPDATE', true);
```
**Technical notes:**
- Update checker only registers when `SettingsController::getPluginLicenseServerUrl()` returns a value
- Self-licensing detection prevents circular update checks (via `PluginLicenseChecker::isSelfLicensing()`)
- Download URLs include license key in `X-License-Key` header for server-side verification
- Uses Symfony HttpClient for server requests with 15s timeout
- Cache TTL configurable from 1-168 hours in settings
- OpenAPI spec updated to version 0.6.0 with full `/update-check` documentation
**Release v0.6.0:**
- Created release package: `releases/wc-licensed-product-0.6.0.zip` (1.1 MB)
- SHA256: `171c8195c586b3b20bac4a806e2d698cdaaf15966e2fd6e1670ec39dac8ab027`
- Tagged as `v0.6.0` and pushed to `main` branch
### 2026-01-27 - Version 0.6.1 - UI Improvements & Bug Fixes
**Overview:**
Bug fix and improvement release addressing admin license testing, auto-update settings, and customer license filtering.
**Implemented:**
- Filter functionality on customer account licenses page (filter by product or domain)
- Split auto-update settings into "Enable Update Notifications" and "Automatically Install Updates"
- WordPress `auto_update_plugin` filter integration for automatic installation
**Bug Fixes:**
- Fixed admin license test popup showing empty product field
- Removed version field from test popup (version_id is only set for version-bound licenses)
- `handleAjaxTestLicense()` now enriches response with product name
**Modified files:**
- `src/Admin/AdminController.php` - Enriched test license response with product name
- `src/Admin/SettingsController.php` - Split auto-update settings, added static helper methods
- `src/Update/PluginUpdateChecker.php` - Added `auto_update_plugin` filter, use new settings methods
- `src/Frontend/AccountController.php` - Added filter functionality with `applyLicenseFilters()` method
- `templates/frontend/licenses.html.twig` - Added filter form with product and domain dropdowns
- `templates/admin/licenses.html.twig` - Removed version row from test license modal
- `assets/css/frontend.css` - Added responsive styles for filter form
- `languages/*` - Updated all translation files
**New methods in SettingsController:**
- `isUpdateNotificationEnabled()` - Check if update notifications are enabled
- `isAutoInstallEnabled()` - Check if auto-install is enabled (requires notifications enabled)
**New methods in AccountController:**
- `applyLicenseFilters()` - Filter licenses by product ID and/or domain
- `getFilterOptions()` - Get unique products and domains for filter dropdowns
**Technical notes:**
- Filter form uses GET parameters: `filter_product` and `filter_domain`
- Auto-install setting is disabled (greyed out) when update notifications are disabled
- License test popup now only shows Product and Expires fields (version removed)
- Domain filter uses case-insensitive partial matching via `stripos()`
**Dependency Updates:**
- Updated `magdev/wc-licensed-product-client` from v0.2.0 to v0.2.1

View File

@@ -50,16 +50,21 @@ code.file-hash {
color: #666; color: #666;
} }
/* License Product Tab - Hidden by default, shown via JS based on product type */ /* License Settings Tab - Hidden by default, shown via JS based on product type */
#woocommerce-product-data .show_if_licensed, /* WooCommerce creates tab with class: {tab_key}_options (licensed_product_options) */
#woocommerce-product-data .show_if_licensed-variable { #woocommerce-product-data ul.wc-tabs li.licensed_product_options {
display: none; display: none;
} }
#woocommerce-product-data .hide_if_licensed { /* When shown, restore proper display for tab list items */
display: none !important; #woocommerce-product-data ul.wc-tabs li.licensed_product_options.wclp-active {
display: block;
} }
/* Variations tab visibility for licensed-variable is handled by WooCommerce */
/* We add show_if_licensed-variable class to the variations tab via PHP filter */
/* Action Buttons */ /* Action Buttons */
.wp-list-table .button-link-delete { .wp-list-table .button-link-delete {
color: #a00; color: #a00;

View File

@@ -37,6 +37,80 @@
color: #383d41; color: #383d41;
} }
/* Filter Form */
.wclp-filter-form {
margin-bottom: 1.5em;
padding: 1em;
background-color: #f8f9fa;
border: 1px solid #e5e5e5;
border-radius: 8px;
}
.wclp-filter-row {
display: flex;
flex-wrap: wrap;
gap: 1em;
align-items: flex-end;
}
.wclp-filter-field {
display: flex;
flex-direction: column;
gap: 0.3em;
flex: 1;
min-width: 150px;
}
.wclp-filter-field label {
font-size: 0.85em;
font-weight: 600;
color: #666;
}
.wclp-filter-field select {
width: 100%;
padding: 0.5em 0.75em;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #fff;
font-size: 0.95em;
}
.wclp-filter-field select:focus {
border-color: #0073aa;
outline: none;
box-shadow: 0 0 0 1px #0073aa;
}
.wclp-filter-actions {
display: flex;
gap: 0.5em;
}
.wclp-filter-actions .button {
padding: 0.5em 1em;
font-size: 0.95em;
white-space: nowrap;
}
@media (max-width: 600px) {
.wclp-filter-row {
flex-direction: column;
}
.wclp-filter-field {
min-width: 100%;
}
.wclp-filter-actions {
width: 100%;
}
.wclp-filter-actions .button {
flex: 1;
}
}
/* License Packages */ /* License Packages */
.woocommerce-licenses { .woocommerce-licenses {
display: flex; display: flex;

View File

@@ -18,12 +18,25 @@
} }
const { getSetting } = wc.wcSettings; const { getSetting } = wc.wcSettings;
const { createElement, useState } = wp.element; const { createElement, useState, useEffect, useCallback } = wp.element;
const { TextControl } = wp.components; const { TextControl } = wp.components;
const { __ } = wp.i18n; const { __ } = wp.i18n;
// Get available exports from blocksCheckout // Get available exports from blocksCheckout
const { ExperimentalOrderMeta } = wc.blocksCheckout; const { ExperimentalOrderMeta, extensionCartUpdate } = wc.blocksCheckout;
// Debounce function for API updates
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Get settings from PHP // Get settings from PHP
const settings = getSetting('wc-licensed-product_data', {}); const settings = getSetting('wc-licensed-product_data', {});
@@ -59,6 +72,23 @@
const [domain, setDomain] = useState(''); const [domain, setDomain] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
// Debounced API update function
const updateStoreApi = useCallback(
debounce((normalizedDomain) => {
if (extensionCartUpdate) {
extensionCartUpdate({
namespace: 'wc-licensed-product',
data: {
licensed_product_domain: normalizedDomain,
},
}).catch(err => {
console.error('[WCLP] Store API update error:', err);
});
}
}, 500),
[]
);
const handleChange = (value) => { const handleChange = (value) => {
const normalized = normalizeDomain(value); const normalized = normalizeDomain(value);
setDomain(normalized); setDomain(normalized);
@@ -67,9 +97,11 @@
setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product')); setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product'));
} else { } else {
setError(''); setError('');
// Update Store API when valid
updateStoreApi(normalized);
} }
// Store in hidden input for form submission // Store in hidden input for form submission (fallback)
const hiddenInput = document.getElementById('wclp-domain-hidden'); const hiddenInput = document.getElementById('wclp-domain-hidden');
if (hiddenInput) { if (hiddenInput) {
hiddenInput.value = normalized; hiddenInput.value = normalized;
@@ -135,6 +167,23 @@
}); });
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
// Debounced API update function
const updateStoreApi = useCallback(
debounce((domainsData) => {
if (extensionCartUpdate) {
extensionCartUpdate({
namespace: 'wc-licensed-product',
data: {
licensed_product_domains: domainsData,
},
}).catch(err => {
console.error('[WCLP] Store API update error:', err);
});
}
}, 500),
[]
);
if (!products.length) { if (!products.length) {
return null; return null;
} }
@@ -174,7 +223,7 @@
setErrors(newErrors); setErrors(newErrors);
// Update hidden field with variation support // Build domain data for Store API
const data = products.map(p => { const data = products.map(p => {
const pKey = getProductKey(p); const pKey = getProductKey(p);
const doms = newDomains[pKey] || []; const doms = newDomains[pKey] || [];
@@ -188,6 +237,10 @@
return entry; return entry;
}).filter(item => item.domains.length > 0); }).filter(item => item.domains.length > 0);
// Update Store API
updateStoreApi(data);
// Update hidden field (fallback)
const hiddenInput = document.getElementById('wclp-domains-hidden'); const hiddenInput = document.getElementById('wclp-domains-hidden');
if (hiddenInput) { if (hiddenInput) {
hiddenInput.value = JSON.stringify(data); hiddenInput.value = JSON.stringify(data);
@@ -273,11 +326,13 @@
if (registerPlugin) { if (registerPlugin) {
registerPlugin('wc-licensed-product-domain-fields', { registerPlugin('wc-licensed-product-domain-fields', {
render: () => createElement( render: () => {
ExperimentalOrderMeta, return createElement(
{}, ExperimentalOrderMeta,
createElement(LicenseDomainsBlock) {},
), createElement(LicenseDomainsBlock)
);
},
scope: 'woocommerce-checkout', scope: 'woocommerce-checkout',
}); });
} }
@@ -379,6 +434,68 @@
} else { } else {
insertionPoint.appendChild(container); insertionPoint.appendChild(container);
} }
// Add event listeners to sync with Store API
const debouncedUpdate = debounce(function() {
if (!extensionCartUpdate) {
return;
}
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
// Collect multi-domain data
const domainsData = settings.licensedProducts.map(function(product) {
const productKey = product.variation_id && product.variation_id > 0
? product.product_id + '_' + product.variation_id
: String(product.product_id);
const domains = [];
for (let i = 0; i < product.quantity; i++) {
const input = container.querySelector('input[name="licensed_domains[' + productKey + '][' + i + ']"]');
if (input && input.value.trim()) {
domains.push(normalizeDomain(input.value));
}
}
const entry = {
product_id: product.product_id,
domains: domains,
};
if (product.variation_id && product.variation_id > 0) {
entry.variation_id = product.variation_id;
}
return entry;
}).filter(function(item) { return item.domains.length > 0; });
extensionCartUpdate({
namespace: 'wc-licensed-product',
data: {
licensed_product_domains: domainsData,
},
}).catch(function(err) {
console.error('[WCLP] Store API update error:', err);
});
} else {
// Single domain
const input = container.querySelector('input[name="licensed_product_domain"]');
if (input) {
const domain = normalizeDomain(input.value);
extensionCartUpdate({
namespace: 'wc-licensed-product',
data: {
licensed_product_domain: domain,
},
}).catch(function(err) {
console.error('[WCLP] Store API update error:', err);
});
}
}
}, 500);
// Attach event listeners to all domain inputs
container.querySelectorAll('input[type="text"]').forEach(function(input) {
input.addEventListener('input', debouncedUpdate);
input.addEventListener('change', debouncedUpdate);
});
}, 2000); }, 2000);
})(); })();

4
composer.lock generated
View File

@@ -12,7 +12,7 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git", "url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
"reference": "5e4b5a970f75d0163c5496581d963a24ade4f276" "reference": "760e1e752a0c088fa634cf7ff678e0735ed525a4"
}, },
"require": { "require": {
"php": "^8.3", "php": "^8.3",
@@ -52,7 +52,7 @@
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues", "issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client" "source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
}, },
"time": "2026-01-26T15:54:37+00:00" "time": "2026-01-27T19:52:12+00:00"
}, },
{ {
"name": "psr/cache", "name": "psr/cache",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"info": { "info": {
"title": "WooCommerce Licensed Product API", "title": "WooCommerce Licensed Product API",
"description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.\n\n## Response Signing (Optional)\n\nWhen the server is configured with `WC_LICENSE_SERVER_SECRET`, all API responses include cryptographic signatures for tamper protection:\n\n- `X-License-Signature`: HMAC-SHA256 signature of the response\n- `X-License-Timestamp`: Unix timestamp when the response was generated\n\nSignature verification prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` library's `SecureLicenseClient` class to automatically verify signatures.", "description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.\n\n## Response Signing (Optional)\n\nWhen the server is configured with `WC_LICENSE_SERVER_SECRET`, all API responses include cryptographic signatures for tamper protection:\n\n- `X-License-Signature`: HMAC-SHA256 signature of the response\n- `X-License-Timestamp`: Unix timestamp when the response was generated\n\nSignature verification prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` library's `SecureLicenseClient` class to automatically verify signatures.",
"version": "0.3.2", "version": "0.6.0",
"contact": { "contact": {
"name": "Marco Graetsch", "name": "Marco Graetsch",
"url": "https://src.bundespruefstelle.ch/magdev", "url": "https://src.bundespruefstelle.ch/magdev",
@@ -332,6 +332,148 @@
} }
} }
} }
},
"/update-check": {
"post": {
"operationId": "checkForUpdates",
"summary": "Check for plugin updates",
"description": "Checks if a newer version of the licensed product is available. Returns WordPress-compatible update information that can be used to integrate with WordPress's native plugin update system.",
"tags": ["Plugin Updates"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCheckRequest"
},
"example": {
"license_key": "ABCD-1234-EFGH-5678",
"domain": "example.com",
"plugin_slug": "my-licensed-plugin",
"current_version": "1.0.0"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/UpdateCheckRequest"
}
}
}
},
"responses": {
"200": {
"description": "Update check completed successfully",
"headers": {
"X-License-Signature": {
"$ref": "#/components/headers/X-License-Signature"
},
"X-License-Timestamp": {
"$ref": "#/components/headers/X-License-Timestamp"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCheckResponse"
},
"examples": {
"update_available": {
"summary": "Update is available",
"value": {
"success": true,
"update_available": true,
"version": "1.2.0",
"slug": "my-licensed-plugin",
"plugin": "my-licensed-plugin/my-licensed-plugin.php",
"download_url": "https://example.com/license-download/123-456-abc123",
"package": "https://example.com/license-download/123-456-abc123",
"last_updated": "2026-01-27",
"tested": "6.7",
"requires": "6.0",
"requires_php": "8.3",
"changelog": "## 1.2.0\n- New feature added\n- Bug fixes",
"package_hash": "sha256:abc123def456...",
"name": "My Licensed Plugin",
"homepage": "https://example.com/product/my-plugin"
}
},
"no_update": {
"summary": "No update available",
"value": {
"success": true,
"update_available": false,
"version": "1.0.0"
}
}
}
}
}
},
"403": {
"description": "License validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"examples": {
"license_invalid": {
"summary": "License is not valid",
"value": {
"success": false,
"update_available": false,
"error": "license_invalid",
"message": "License validation failed."
}
},
"domain_mismatch": {
"summary": "Domain mismatch",
"value": {
"success": false,
"update_available": false,
"error": "domain_mismatch",
"message": "This license is not valid for this domain."
}
}
}
}
}
},
"404": {
"description": "License or product not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"examples": {
"license_not_found": {
"summary": "License not found",
"value": {
"success": false,
"update_available": false,
"error": "license_not_found",
"message": "License not found."
}
},
"product_not_found": {
"summary": "Product not found",
"value": {
"success": false,
"update_available": false,
"error": "product_not_found",
"message": "Licensed product not found."
}
}
}
}
}
},
"429": {
"$ref": "#/components/responses/RateLimitExceeded"
}
}
}
} }
}, },
"components": { "components": {
@@ -516,6 +658,130 @@
"description": "Seconds until rate limit resets" "description": "Seconds until rate limit resets"
} }
} }
},
"UpdateCheckRequest": {
"type": "object",
"required": ["license_key", "domain"],
"properties": {
"license_key": {
"type": "string",
"description": "The license key to validate (format: XXXX-XXXX-XXXX-XXXX)",
"maxLength": 64,
"example": "ABCD-1234-EFGH-5678"
},
"domain": {
"type": "string",
"description": "The domain the plugin is installed on",
"maxLength": 255,
"example": "example.com"
},
"plugin_slug": {
"type": "string",
"description": "The plugin slug (optional, for identification)",
"example": "my-licensed-plugin"
},
"current_version": {
"type": "string",
"description": "Currently installed version for comparison",
"example": "1.0.0"
}
}
},
"UpdateCheckResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"description": "Whether the request was successful"
},
"update_available": {
"type": "boolean",
"description": "Whether an update is available"
},
"version": {
"type": "string",
"description": "Latest available version"
},
"slug": {
"type": "string",
"description": "Plugin slug for WordPress"
},
"plugin": {
"type": "string",
"description": "Plugin basename (slug/slug.php)"
},
"download_url": {
"type": "string",
"format": "uri",
"description": "Secure download URL for the update package"
},
"package": {
"type": "string",
"format": "uri",
"description": "Alias for download_url (WordPress compatibility)"
},
"last_updated": {
"type": "string",
"format": "date",
"description": "Date of the latest release"
},
"tested": {
"type": "string",
"description": "Highest WordPress version tested with"
},
"requires": {
"type": "string",
"description": "Minimum required WordPress version"
},
"requires_php": {
"type": "string",
"description": "Minimum required PHP version"
},
"changelog": {
"type": "string",
"description": "Release notes/changelog for the update"
},
"package_hash": {
"type": "string",
"description": "SHA256 hash of the package for integrity verification",
"example": "sha256:abc123..."
},
"name": {
"type": "string",
"description": "Product name"
},
"homepage": {
"type": "string",
"format": "uri",
"description": "Product homepage URL"
},
"icons": {
"type": "object",
"description": "Plugin icons for WordPress admin",
"properties": {
"1x": {
"type": "string",
"format": "uri"
},
"2x": {
"type": "string",
"format": "uri"
}
}
},
"sections": {
"type": "object",
"description": "Content sections for plugin info modal",
"properties": {
"description": {
"type": "string"
},
"changelog": {
"type": "string"
}
}
}
}
} }
}, },
"responses": { "responses": {
@@ -577,6 +843,10 @@
{ {
"name": "License Activation", "name": "License Activation",
"description": "Activate licenses on domains" "description": "Activate licenses on domains"
},
{
"name": "Plugin Updates",
"description": "Check for plugin updates via WordPress-compatible API"
} }
] ]
} }

Binary file not shown.

View File

@@ -0,0 +1 @@
814710ad899529d0015494e4b332eace7d8e55aeda381fdf61f99274c0bf910c wc-licensed-product-0.5.13.zip

View File

@@ -0,0 +1 @@
47407de49bae4c649644af64e87b44b32fb30eeb2d50890ff8c4bbb741059278 wc-licensed-product-0.5.15.zip

View File

@@ -0,0 +1 @@
171c8195c586b3b20bac4a806e2d698cdaaf15966e2fd6e1670ec39dac8ab027 releases/wc-licensed-product-0.6.0.zip

View File

@@ -0,0 +1 @@
f1f1cbdfdd6cda7b20cbd2b88ab4697cde38d987e04cda1f52e885d7818d32f5 wc-licensed-product-0.6.1.zip

View File

@@ -379,6 +379,19 @@ final class AdminController
// Validate the license using LicenseManager // Validate the license using LicenseManager
$result = $this->licenseManager->validateLicense($licenseKey, $domain); $result = $this->licenseManager->validateLicense($licenseKey, $domain);
// Enrich result with product name for display in the popup
if (!empty($result['valid']) && isset($result['license'])) {
// Get product name
$productId = $result['license']['product_id'] ?? null;
if ($productId) {
$product = wc_get_product($productId);
$result['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
}
// Flatten expires_at for easier access in JavaScript
$result['expires_at'] = $result['license']['expires_at'] ?? null;
}
wp_send_json_success($result); wp_send_json_success($result);
} }
@@ -1605,12 +1618,11 @@ final class AdminController
if (result.valid) { if (result.valid) {
html = '<div class="notice notice-success inline"><p><strong>✓ <?php echo esc_js(__('License is VALID', 'wc-licensed-product')); ?></strong></p></div>'; html = '<div class="notice notice-success inline"><p><strong>✓ <?php echo esc_js(__('License is VALID', 'wc-licensed-product')); ?></strong></p></div>';
html += '<table class="widefat striped"><tbody>'; html += '<table class="widefat striped"><tbody>';
html += '<tr><th><?php echo esc_js(__('Product', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.product_name || '-') + '</td></tr>'; html += '<tr><th><?php echo esc_js(__('Product', 'wc-licensed-product')); ?></th><td><strong>' + escapeHtml(result.product_name || '-') + '</strong></td></tr>';
html += '<tr><th><?php echo esc_js(__('Version', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.version || '-') + '</td></tr>';
if (result.expires_at) { if (result.expires_at) {
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.expires_at) + '</td></tr>'; html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.expires_at) + '</td></tr>';
} else { } else {
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td><?php echo esc_js(__('Lifetime', 'wc-licensed-product')); ?></td></tr>'; html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td><span class="license-lifetime"><?php echo esc_js(__('Lifetime', 'wc-licensed-product')); ?></span></td></tr>';
} }
html += '</tbody></table>'; html += '</tbody></table>';
} else { } else {

View File

@@ -83,7 +83,7 @@ final class OrderLicenseController
$hasLicensedProduct = false; $hasLicensedProduct = false;
foreach ($order->get_items() as $item) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if ($product && $product->is_type('licensed')) { if ($product && $this->licenseManager->isLicensedProduct($product)) {
$hasLicensedProduct = true; $hasLicensedProduct = true;
break; break;
} }
@@ -162,7 +162,7 @@ final class OrderLicenseController
// Legacy: one license per licensed product // Legacy: one license per licensed product
foreach ($order->get_items() as $item) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if ($product && $product->is_type('licensed')) { if ($product && $this->licenseManager->isLicensedProduct($product)) {
$expectedLicenses++; $expectedLicenses++;
} }
} }
@@ -567,7 +567,7 @@ final class OrderLicenseController
foreach ($order->get_items() as $item) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if (!$product || !$product->is_type('licensed')) { if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
continue; continue;
} }
@@ -615,7 +615,7 @@ final class OrderLicenseController
foreach ($order->get_items() as $item) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if (!$product || !$product->is_type('licensed')) { if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
continue; continue;
} }

View File

@@ -62,6 +62,7 @@ final class SettingsController
{ {
return [ return [
'' => __('Plugin License', 'wc-licensed-product'), '' => __('Plugin License', 'wc-licensed-product'),
'auto-updates' => __('Auto-Updates', 'wc-licensed-product'),
'defaults' => __('Default Settings', 'wc-licensed-product'), 'defaults' => __('Default Settings', 'wc-licensed-product'),
'notifications' => __('Notifications', 'wc-licensed-product'), 'notifications' => __('Notifications', 'wc-licensed-product'),
]; ];
@@ -112,6 +113,7 @@ final class SettingsController
$currentSection = $this->getCurrentSection(); $currentSection = $this->getCurrentSection();
return match ($currentSection) { return match ($currentSection) {
'auto-updates' => $this->getAutoUpdatesSettings(),
'defaults' => $this->getDefaultsSettings(), 'defaults' => $this->getDefaultsSettings(),
'notifications' => $this->getNotificationsSettings(), 'notifications' => $this->getNotificationsSettings(),
default => $this->getPluginLicenseSettings(), default => $this->getPluginLicenseSettings(),
@@ -160,6 +162,56 @@ final class SettingsController
]; ];
} }
/**
* Get auto-updates settings
*/
private function getAutoUpdatesSettings(): array
{
$autoInstallDisabled = !self::isUpdateNotificationEnabled();
return [
'auto_update_section_title' => [
'name' => __('Auto-Updates', 'wc-licensed-product'),
'type' => 'title',
'desc' => __('Configure automatic plugin updates from the license server.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_section_auto_update',
],
'update_notification_enabled' => [
'name' => __('Enable Update Notifications', 'wc-licensed-product'),
'type' => 'checkbox',
'desc' => __('Check for and display available updates from the license server in WordPress admin.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_update_notification_enabled',
'default' => 'yes',
],
'plugin_auto_install_enabled' => [
'name' => __('Automatically Install Updates', 'wc-licensed-product'),
'type' => 'checkbox',
'desc' => $autoInstallDisabled
? __('Enable "Update Notifications" above to use this option.', 'wc-licensed-product')
: __('Automatically install updates when they become available (requires update notifications enabled).', 'wc-licensed-product'),
'id' => 'wc_licensed_product_plugin_auto_install_enabled',
'default' => 'no',
'custom_attributes' => $autoInstallDisabled ? ['disabled' => 'disabled'] : [],
],
'update_check_frequency' => [
'name' => __('Check Frequency (Hours)', 'wc-licensed-product'),
'type' => 'number',
'desc' => __('How often to check for updates (in hours).', 'wc-licensed-product'),
'id' => 'wc_licensed_product_update_check_frequency',
'default' => '12',
'custom_attributes' => [
'min' => '1',
'max' => '168',
'step' => '1',
],
],
'auto_update_section_end' => [
'type' => 'sectionend',
'id' => 'wc_licensed_product_section_auto_update_end',
],
];
}
/** /**
* Get default license settings * Get default license settings
*/ */
@@ -460,6 +512,44 @@ final class SettingsController
return !empty($secret) ? (string) $secret : null; return !empty($secret) ? (string) $secret : null;
} }
/**
* Check if update notifications are enabled
*/
public static function isUpdateNotificationEnabled(): bool
{
return get_option('wc_licensed_product_update_notification_enabled', 'yes') === 'yes';
}
/**
* Check if auto-updates are enabled (legacy alias for isUpdateNotificationEnabled)
*/
public static function isAutoUpdateEnabled(): bool
{
return self::isUpdateNotificationEnabled();
}
/**
* Check if automatic installation of updates is enabled
*/
public static function isAutoInstallEnabled(): bool
{
// Auto-install requires notifications to be enabled first
if (!self::isUpdateNotificationEnabled()) {
return false;
}
return get_option('wc_licensed_product_plugin_auto_install_enabled', 'no') === 'yes';
}
/**
* Get update check frequency in hours
*/
public static function getUpdateCheckFrequency(): int
{
$value = get_option('wc_licensed_product_update_check_frequency', 12);
return max(1, min(168, (int) $value));
}
/** /**
* Handle AJAX verify license request * Handle AJAX verify license request
*/ */

View File

@@ -43,25 +43,21 @@ final class VersionAdminController
/** /**
* Add versions meta box to product edit page * Add versions meta box to product edit page
* Always adds the meta box - visibility is controlled via CSS/JavaScript based on product type
*/ */
public function addVersionsMetaBox(): void public function addVersionsMetaBox(): void
{ {
global $post; global $post;
// Only add meta box for licensed products or new products
if ($post && $post->post_type === 'product') { if ($post && $post->post_type === 'product') {
$product = wc_get_product($post->ID); add_meta_box(
// Show for licensed products or new products (where type might be selected later) 'wc_licensed_product_versions',
if (!$product || $product->is_type('licensed') || $post->post_status === 'auto-draft') { __('Product Versions', 'wc-licensed-product'),
add_meta_box( [$this, 'renderVersionsMetaBox'],
'wc_licensed_product_versions', 'product',
__('Product Versions', 'wc-licensed-product'), 'normal',
[$this, 'renderVersionsMetaBox'], 'high'
'product', );
'normal',
'high'
);
}
} }
} }
@@ -280,12 +276,13 @@ final class VersionAdminController
} }
// Verify product exists and is of type licensed // Verify product exists and is of type licensed
$product = wc_get_product($productId); // Use WC_Product_Factory::get_product_type() for reliable type detection
if (!$product) { $productType = \WC_Product_Factory::get_product_type($productId);
if (!$productType) {
wp_send_json_error(['message' => __('Product not found.', 'wc-licensed-product')]); wp_send_json_error(['message' => __('Product not found.', 'wc-licensed-product')]);
} }
if (!$product->is_type('licensed')) { if (!in_array($productType, ['licensed', 'licensed-variable'], true)) {
wp_send_json_error(['message' => __('This product is not a licensed product.', 'wc-licensed-product')]); wp_send_json_error(['message' => __('This product is not a licensed product.', 'wc-licensed-product')]);
} }

View File

@@ -0,0 +1,352 @@
<?php
/**
* Update Controller
*
* REST API endpoint for plugin update checks
*
* @package Jeremias\WcLicensedProduct\Api
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Api;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\VersionManager;
use Jeremias\WcLicensedProduct\Product\ProductVersion;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Handles REST API endpoint for plugin update checks
*
* This endpoint allows licensed plugins to check for updates from this WooCommerce store.
* It validates the license and returns WordPress-compatible update information.
*/
final class UpdateController
{
private const NAMESPACE = 'wc-licensed-product/v1';
/**
* Default rate limit: requests per window per IP
*/
private const DEFAULT_RATE_LIMIT = 30;
/**
* Default rate limit window in seconds
*/
private const DEFAULT_RATE_WINDOW = 60;
private LicenseManager $licenseManager;
private VersionManager $versionManager;
public function __construct(LicenseManager $licenseManager, VersionManager $versionManager)
{
$this->licenseManager = $licenseManager;
$this->versionManager = $versionManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
add_action('rest_api_init', [$this, 'registerRoutes']);
}
/**
* Get the configured rate limit (requests per window)
*/
private function getRateLimit(): int
{
return defined('WC_LICENSE_RATE_LIMIT')
? (int) WC_LICENSE_RATE_LIMIT
: self::DEFAULT_RATE_LIMIT;
}
/**
* Get the configured rate limit window in seconds
*/
private function getRateWindow(): int
{
return defined('WC_LICENSE_RATE_WINDOW')
? (int) WC_LICENSE_RATE_WINDOW
: self::DEFAULT_RATE_WINDOW;
}
/**
* Check rate limit for current IP
*
* @return WP_REST_Response|null Returns error response if rate limited, null if OK
*/
private function checkRateLimit(): ?WP_REST_Response
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$transientKey = 'wclp_update_rate_' . md5($ip);
$rateLimit = $this->getRateLimit();
$rateWindow = $this->getRateWindow();
$data = get_transient($transientKey);
if ($data === false) {
set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow);
return null;
}
$count = (int) ($data['count'] ?? 0);
$start = (int) ($data['start'] ?? time());
if (time() - $start >= $rateWindow) {
set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow);
return null;
}
if ($count >= $rateLimit) {
$retryAfter = $rateWindow - (time() - $start);
$response = new WP_REST_Response([
'success' => false,
'error' => 'rate_limit_exceeded',
'message' => __('Too many requests. Please try again later.', 'wc-licensed-product'),
'retry_after' => $retryAfter,
], 429);
$response->header('Retry-After', (string) $retryAfter);
return $response;
}
set_transient($transientKey, ['count' => $count + 1, 'start' => $start], $rateWindow);
return null;
}
/**
* Register REST API routes
*/
public function registerRoutes(): void
{
register_rest_route(self::NAMESPACE, '/update-check', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handleUpdateCheck'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ($value): bool {
$len = strlen($value);
return !empty($value) && $len >= 8 && $len <= 64;
},
],
'domain' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ($value): bool {
return !empty($value) && strlen($value) <= 255;
},
],
'plugin_slug' => [
'required' => false,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
'current_version' => [
'required' => false,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
}
/**
* Handle update check request
*/
public function handleUpdateCheck(WP_REST_Request $request): WP_REST_Response
{
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
$currentVersion = $request->get_param('current_version');
// Validate license
$validationResult = $this->licenseManager->validateLicense($licenseKey, $domain);
if (!$validationResult['valid']) {
return new WP_REST_Response([
'success' => false,
'update_available' => false,
'error' => $validationResult['error'] ?? 'license_invalid',
'message' => $validationResult['message'] ?? __('License validation failed.', 'wc-licensed-product'),
], $validationResult['error'] === 'license_not_found' ? 404 : 403);
}
// Get license to access product ID
$license = $this->licenseManager->getLicenseByKey($licenseKey);
if (!$license) {
return new WP_REST_Response([
'success' => false,
'update_available' => false,
'error' => 'license_not_found',
'message' => __('License not found.', 'wc-licensed-product'),
], 404);
}
$productId = $license->getProductId();
$product = wc_get_product($productId);
if (!$product) {
return new WP_REST_Response([
'success' => false,
'update_available' => false,
'error' => 'product_not_found',
'message' => __('Licensed product not found.', 'wc-licensed-product'),
], 404);
}
// Get latest version based on major version binding
$latestVersion = $this->getLatestVersionForLicense($license);
if (!$latestVersion) {
return new WP_REST_Response([
'success' => true,
'update_available' => false,
'version' => $currentVersion ?? '0.0.0',
'message' => __('No versions available for this product.', 'wc-licensed-product'),
]);
}
// Check if update is available
$updateAvailable = $currentVersion
? version_compare($latestVersion->getVersion(), $currentVersion, '>')
: true;
// Build response
$response = $this->buildUpdateResponse($product, $latestVersion, $license, $updateAvailable);
return new WP_REST_Response($response);
}
/**
* Get latest version for a license, respecting major version binding
*/
private function getLatestVersionForLicense($license): ?ProductVersion
{
$productId = $license->getProductId();
// Check if license is bound to a specific version
$versionId = $license->getVersionId();
if ($versionId) {
$boundVersion = $this->versionManager->getVersionById($versionId);
if ($boundVersion) {
// Get latest version for this major version
return $this->versionManager->getLatestVersionForMajor(
$productId,
$boundVersion->getMajorVersion()
);
}
}
// No version binding, return latest overall
return $this->versionManager->getLatestVersion($productId);
}
/**
* Build WordPress-compatible update response
*/
private function buildUpdateResponse($product, ProductVersion $version, $license, bool $updateAvailable): array
{
$productSlug = $product->get_slug();
// Generate secure download URL
$downloadUrl = $this->generateUpdateDownloadUrl($license->getId(), $version->getId());
$response = [
'success' => true,
'update_available' => $updateAvailable,
'version' => $version->getVersion(),
'slug' => $productSlug,
'plugin' => $productSlug . '/' . $productSlug . '.php',
'download_url' => $downloadUrl,
'package' => $downloadUrl,
'last_updated' => $version->getReleasedAt()->format('Y-m-d'),
'tested' => $this->getTestedWpVersion(),
'requires' => $this->getRequiredWpVersion(),
'requires_php' => $this->getRequiredPhpVersion(),
];
// Add changelog if available
if ($version->getReleaseNotes()) {
$response['changelog'] = $version->getReleaseNotes();
$response['sections'] = [
'description' => $product->get_short_description() ?: $product->get_description(),
'changelog' => $version->getReleaseNotes(),
];
}
// Add package hash for integrity verification
if ($version->getFileHash()) {
$response['package_hash'] = 'sha256:' . $version->getFileHash();
}
// Add product name and homepage
$response['name'] = $product->get_name();
$response['homepage'] = get_permalink($product->get_id());
// Add icons if product has featured image
$imageId = $product->get_image_id();
if ($imageId) {
$iconUrl = wp_get_attachment_image_url($imageId, 'thumbnail');
$iconUrl2x = wp_get_attachment_image_url($imageId, 'medium');
if ($iconUrl) {
$response['icons'] = [
'1x' => $iconUrl,
'2x' => $iconUrl2x ?: $iconUrl,
];
}
}
return $response;
}
/**
* Generate secure download URL for updates
*/
private function generateUpdateDownloadUrl(int $licenseId, int $versionId): string
{
$data = $licenseId . '-' . $versionId . '-' . wp_salt('auth');
$hash = substr(hash('sha256', $data), 0, 16);
$downloadKey = $licenseId . '-' . $versionId . '-' . $hash;
return home_url('license-download/' . $downloadKey);
}
/**
* Get tested WordPress version from plugin headers
*/
private function getTestedWpVersion(): string
{
return get_option('wc_licensed_product_tested_wp', '6.7');
}
/**
* Get required WordPress version from plugin headers
*/
private function getRequiredWpVersion(): string
{
return get_option('wc_licensed_product_requires_wp', '6.0');
}
/**
* Get required PHP version
*/
private function getRequiredPhpVersion(): string
{
return get_option('wc_licensed_product_requires_php', '8.3');
}
}

View File

@@ -112,10 +112,12 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
public function get_script_data(): array public function get_script_data(): array
{ {
$isMultiDomain = SettingsController::isMultiDomainEnabled(); $isMultiDomain = SettingsController::isMultiDomainEnabled();
$licensedProducts = $this->getLicensedProductsFromCart();
$hasLicensedProducts = !empty($licensedProducts);
return [ return [
'hasLicensedProducts' => $this->cartHasLicensedProducts(), 'hasLicensedProducts' => $hasLicensedProducts,
'licensedProducts' => $this->getLicensedProductsFromCart(), 'licensedProducts' => $licensedProducts,
'isMultiDomainEnabled' => $isMultiDomain, 'isMultiDomainEnabled' => $isMultiDomain,
'fieldPlaceholder' => __('example.com', 'wc-licensed-product'), 'fieldPlaceholder' => __('example.com', 'wc-licensed-product'),
'fieldDescription' => $isMultiDomain 'fieldDescription' => $isMultiDomain
@@ -151,8 +153,11 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
} }
$licensedProducts = []; $licensedProducts = [];
foreach (WC()->cart->get_cart() as $cartItem) { $cartContents = WC()->cart->get_cart();
foreach ($cartContents as $cartKey => $cartItem) {
$product = $cartItem['data']; $product = $cartItem['data'];
if (!$product) { if (!$product) {
continue; continue;
} }
@@ -171,11 +176,12 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
} }
// Check for variations of licensed-variable products // Check for variations of licensed-variable products
if ($product->is_type('variation')) { // Use WC_Product_Factory::get_product_type() for reliable parent type check
$parentId = $product->get_parent_id(); $parentId = $product->get_parent_id();
$parent = wc_get_product($parentId); if ($parentId) {
$parentType = \WC_Product_Factory::get_product_type($parentId);
if ($parent && $parent->is_type('licensed-variable')) { if ($parentType === 'licensed-variable') {
$variationId = $product->get_id(); $variationId = $product->get_id();
// Get duration label if it's a LicensedProductVariation // Get duration label if it's a LicensedProductVariation

View File

@@ -67,8 +67,9 @@ final class CheckoutController
} }
$licensedProducts = []; $licensedProducts = [];
foreach (WC()->cart->get_cart() as $cartItem) { foreach (WC()->cart->get_cart() as $cartItemKey => $cartItem) {
$product = $cartItem['data']; $product = $cartItem['data'];
if (!$product) { if (!$product) {
continue; continue;
} }
@@ -87,11 +88,12 @@ final class CheckoutController
} }
// Check for variations of licensed-variable products // Check for variations of licensed-variable products
if ($product->is_type('variation')) { // Use WC_Product_Factory::get_product_type() for reliable parent type check
$parentId = $product->get_parent_id(); $parentId = $product->get_parent_id();
$parent = wc_get_product($parentId); if ($parentId) {
$parentType = \WC_Product_Factory::get_product_type($parentId);
if ($parent && $parent->is_type('licensed-variable')) { if ($parentType === 'licensed-variable') {
$variationId = $product->get_id(); $variationId = $product->get_id();
// Use combination key to allow same product with different variations // Use combination key to allow same product with different variations
$key = "{$parentId}_{$variationId}"; $key = "{$parentId}_{$variationId}";
@@ -127,6 +129,7 @@ final class CheckoutController
public function addDomainField(): void public function addDomainField(): void
{ {
$licensedProducts = $this->getLicensedProductsFromCart(); $licensedProducts = $this->getLicensedProductsFromCart();
if (empty($licensedProducts)) { if (empty($licensedProducts)) {
return; return;
} }
@@ -401,6 +404,7 @@ final class CheckoutController
public function saveDomainField(int $orderId): void public function saveDomainField(int $orderId): void
{ {
$licensedProducts = $this->getLicensedProductsFromCart(); $licensedProducts = $this->getLicensedProductsFromCart();
if (empty($licensedProducts)) { if (empty($licensedProducts)) {
return; return;
} }

View File

@@ -106,23 +106,104 @@ final class AccountController
return; return;
} }
// Get filter parameters from URL
$filterProductId = isset($_GET['filter_product']) ? absint($_GET['filter_product']) : 0;
$filterDomain = isset($_GET['filter_domain']) ? sanitize_text_field(wp_unslash($_GET['filter_domain'])) : '';
$licenses = $this->licenseManager->getLicensesByCustomer($customerId); $licenses = $this->licenseManager->getLicensesByCustomer($customerId);
// Apply filters
$filteredLicenses = $this->applyLicenseFilters($licenses, $filterProductId, $filterDomain);
// Group licenses by product+order into "packages" // Group licenses by product+order into "packages"
$packages = $this->groupLicensesIntoPackages($licenses); $packages = $this->groupLicensesIntoPackages($filteredLicenses);
// Get unique products and domains for filter dropdowns
$filterOptions = $this->getFilterOptions($licenses);
try { try {
echo $this->twig->render('frontend/licenses.html.twig', [ echo $this->twig->render('frontend/licenses.html.twig', [
'packages' => $packages, 'packages' => $packages,
'has_packages' => !empty($packages), 'has_packages' => !empty($packages),
'signing_enabled' => ResponseSigner::isSigningEnabled(), 'signing_enabled' => ResponseSigner::isSigningEnabled(),
'filter_products' => $filterOptions['products'],
'filter_domains' => $filterOptions['domains'],
'current_filter_product' => $filterProductId,
'current_filter_domain' => $filterDomain,
'is_filtered' => $filterProductId > 0 || !empty($filterDomain),
'licenses_url' => wc_get_account_endpoint_url('licenses'),
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
// Fallback to PHP template if Twig fails // Fallback to PHP template if Twig fails
$this->displayLicensesFallback($packages); $this->displayLicensesFallback($packages, $filterOptions, $filterProductId, $filterDomain);
} }
} }
/**
* Apply filters to licenses
*
* @param array $licenses Array of License objects
* @param int $productId Filter by product ID (0 for all)
* @param string $domain Filter by domain (empty for all)
* @return array Filtered array of License objects
*/
private function applyLicenseFilters(array $licenses, int $productId, string $domain): array
{
if ($productId === 0 && empty($domain)) {
return $licenses;
}
return array_filter($licenses, function ($license) use ($productId, $domain) {
// Filter by product
if ($productId > 0 && $license->getProductId() !== $productId) {
return false;
}
// Filter by domain (partial match)
if (!empty($domain) && stripos($license->getDomain(), $domain) === false) {
return false;
}
return true;
});
}
/**
* Get unique filter options from licenses
*
* @param array $licenses Array of License objects
* @return array Array with 'products' and 'domains' keys
*/
private function getFilterOptions(array $licenses): array
{
$products = [];
$domains = [];
foreach ($licenses as $license) {
// Collect unique products
$productId = $license->getProductId();
if (!isset($products[$productId])) {
$product = wc_get_product($productId);
$products[$productId] = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
}
// Collect unique domains
$domain = $license->getDomain();
if (!in_array($domain, $domains, true)) {
$domains[] = $domain;
}
}
// Sort products by name, domains alphabetically
asort($products);
sort($domains);
return [
'products' => $products,
'domains' => $domains,
];
}
/** /**
* Group licenses into packages by product+order * Group licenses into packages by product+order
* *
@@ -217,10 +298,67 @@ final class AccountController
/** /**
* Fallback display method if Twig is unavailable * Fallback display method if Twig is unavailable
*/ */
private function displayLicensesFallback(array $packages): void private function displayLicensesFallback(
{ array $packages,
array $filterOptions = [],
int $currentFilterProduct = 0,
string $currentFilterDomain = ''
): void {
$isFiltered = $currentFilterProduct > 0 || !empty($currentFilterDomain);
$licensesUrl = wc_get_account_endpoint_url('licenses');
// Display filter form if we have filter options
if (!empty($filterOptions['products']) || !empty($filterOptions['domains'])) {
?>
<div class="wclp-filter-form">
<form method="get" action="<?php echo esc_url($licensesUrl); ?>">
<div class="wclp-filter-row">
<?php if (!empty($filterOptions['products'])): ?>
<div class="wclp-filter-field">
<label for="filter_product"><?php esc_html_e('Product', 'wc-licensed-product'); ?></label>
<select name="filter_product" id="filter_product">
<option value=""><?php esc_html_e('All Products', 'wc-licensed-product'); ?></option>
<?php foreach ($filterOptions['products'] as $id => $name): ?>
<option value="<?php echo esc_attr($id); ?>" <?php selected($currentFilterProduct, $id); ?>>
<?php echo esc_html($name); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<?php if (!empty($filterOptions['domains'])): ?>
<div class="wclp-filter-field">
<label for="filter_domain"><?php esc_html_e('Domain', 'wc-licensed-product'); ?></label>
<select name="filter_domain" id="filter_domain">
<option value=""><?php esc_html_e('All Domains', 'wc-licensed-product'); ?></option>
<?php foreach ($filterOptions['domains'] as $domain): ?>
<option value="<?php echo esc_attr($domain); ?>" <?php selected($currentFilterDomain, $domain); ?>>
<?php echo esc_html($domain); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div class="wclp-filter-actions">
<button type="submit" class="button"><?php esc_html_e('Filter', 'wc-licensed-product'); ?></button>
<?php if ($isFiltered): ?>
<a href="<?php echo esc_url($licensesUrl); ?>" class="button"><?php esc_html_e('Clear', 'wc-licensed-product'); ?></a>
<?php endif; ?>
</div>
</div>
</form>
</div>
<?php
}
if (empty($packages)) { if (empty($packages)) {
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>'; if ($isFiltered) {
echo '<p>' . esc_html__('No licenses found matching your filters.', 'wc-licensed-product') . '</p>';
} else {
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
}
return; return;
} }

View File

@@ -31,6 +31,7 @@ final class Installer
{ {
self::createTables(); self::createTables();
self::createCacheDir(); self::createCacheDir();
self::registerProductTypes();
// Set version in options // Set version in options
update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION); update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION);
@@ -43,6 +44,28 @@ final class Installer
flush_rewrite_rules(); flush_rewrite_rules();
} }
/**
* Register custom product type terms in the product_type taxonomy
* This is required for WC_Product_Factory::get_product_type() to work correctly
*/
public static function registerProductTypes(): void
{
// Ensure WooCommerce taxonomies are registered
if (!taxonomy_exists('product_type')) {
return;
}
// Register 'licensed' product type term if it doesn't exist
if (!term_exists('licensed', 'product_type')) {
wp_insert_term('licensed', 'product_type');
}
// Register 'licensed-variable' product type term if it doesn't exist
if (!term_exists('licensed-variable', 'product_type')) {
wp_insert_term('licensed-variable', 'product_type');
}
}
/** /**
* Run on plugin deactivation * Run on plugin deactivation
*/ */

View File

@@ -37,10 +37,18 @@ class LicenseManager
return true; return true;
} }
// Check for our custom variation class
if ($product instanceof LicensedProductVariation) {
return true;
}
// Variation of a licensed-variable product // Variation of a licensed-variable product
if ($product->is_type('variation') && $product->get_parent_id()) { // Use WC_Product_Factory::get_product_type() for reliable parent type check
$parent = wc_get_product($product->get_parent_id()); // This queries the database directly and doesn't depend on product class loading
if ($parent && $parent->is_type('licensed-variable')) { $parentId = $product->get_parent_id();
if ($parentId) {
$parentType = \WC_Product_Factory::get_product_type($parentId);
if ($parentType === 'licensed-variable') {
return true; return true;
} }
} }
@@ -101,10 +109,10 @@ class LicenseManager
// For variations, load the variation; otherwise load the parent product // For variations, load the variation; otherwise load the parent product
if ($variationId) { if ($variationId) {
$settingsProduct = wc_get_product($variationId); $settingsProduct = wc_get_product($variationId);
$parentProduct = wc_get_product($productId);
// Verify parent is licensed-variable // Verify parent is licensed-variable using DB-level type check
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) { $parentType = \WC_Product_Factory::get_product_type($productId);
if ($parentType !== 'licensed-variable') {
return null; return null;
} }

View File

@@ -17,6 +17,7 @@ use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Admin\VersionAdminController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
use Jeremias\WcLicensedProduct\Api\ResponseSigner; use Jeremias\WcLicensedProduct\Api\ResponseSigner;
use Jeremias\WcLicensedProduct\Api\RestApiController; use Jeremias\WcLicensedProduct\Api\RestApiController;
use Jeremias\WcLicensedProduct\Api\UpdateController;
use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration; use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration;
use Jeremias\WcLicensedProduct\Checkout\CheckoutController; use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
use Jeremias\WcLicensedProduct\Checkout\StoreApiExtension; use Jeremias\WcLicensedProduct\Checkout\StoreApiExtension;
@@ -27,6 +28,7 @@ use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker; use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
use Jeremias\WcLicensedProduct\Product\LicensedProductType; use Jeremias\WcLicensedProduct\Product\LicensedProductType;
use Jeremias\WcLicensedProduct\Product\VersionManager; use Jeremias\WcLicensedProduct\Product\VersionManager;
use Jeremias\WcLicensedProduct\Update\PluginUpdateChecker;
use Twig\Environment; use Twig\Environment;
use Twig\Loader\FilesystemLoader; use Twig\Loader\FilesystemLoader;
@@ -139,8 +141,9 @@ final class Plugin
new AccountController($this->twig, $this->licenseManager, $this->versionManager, $this->downloadController); new AccountController($this->twig, $this->licenseManager, $this->versionManager, $this->downloadController);
} }
// Always initialize REST API and email controller // Always initialize REST API, update API, and email controller
new RestApiController($this->licenseManager); new RestApiController($this->licenseManager);
new UpdateController($this->licenseManager, $this->versionManager);
new LicenseEmailController($this->licenseManager); new LicenseEmailController($this->licenseManager);
// Initialize response signing if server secret is configured // Initialize response signing if server secret is configured
@@ -162,6 +165,12 @@ final class Plugin
add_action('admin_notices', [$this, 'showUnlicensedNotice']); add_action('admin_notices', [$this, 'showUnlicensedNotice']);
} }
} }
// Initialize update checker if license server is configured (client-side updates)
$serverUrl = SettingsController::getPluginLicenseServerUrl();
if (!empty($serverUrl) && !$licenseChecker->isSelfLicensing()) {
PluginUpdateChecker::getInstance()->register();
}
} }
/** /**
@@ -210,6 +219,7 @@ final class Plugin
// Try new multi-domain format first // Try new multi-domain format first
$domainData = $order->get_meta('_licensed_product_domains'); $domainData = $order->get_meta('_licensed_product_domains');
if (!empty($domainData) && is_array($domainData)) { if (!empty($domainData) && is_array($domainData)) {
$this->generateLicensesMultiDomain($order, $domainData); $this->generateLicensesMultiDomain($order, $domainData);
return; return;
@@ -244,7 +254,12 @@ final class Plugin
// Generate licenses for each licensed product // Generate licenses for each licensed product
foreach ($order->get_items() as $item) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
if (!$product) {
continue;
}
if (!$this->licenseManager->isLicensedProduct($product)) {
continue; continue;
} }
@@ -278,12 +293,14 @@ final class Plugin
private function generateLicensesSingleDomain(\WC_Order $order): void private function generateLicensesSingleDomain(\WC_Order $order): void
{ {
$domain = $order->get_meta('_licensed_product_domain'); $domain = $order->get_meta('_licensed_product_domain');
if (empty($domain)) { if (empty($domain)) {
return; return;
} }
foreach ($order->get_items() as $item) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if ($product && $this->licenseManager->isLicensedProduct($product)) { if ($product && $this->licenseManager->isLicensedProduct($product)) {
// Get the parent product ID (for variations, this is the main product) // Get the parent product ID (for variations, this is the main product)
$productId = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id(); $productId = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();

View File

@@ -30,6 +30,9 @@ final class LicensedProductType
*/ */
private function registerHooks(): void private function registerHooks(): void
{ {
// Ensure product type terms exist in taxonomy (for WC_Product_Factory::get_product_type())
add_action('woocommerce_init', [$this, 'ensureProductTypeTermsExist']);
// Register product types // Register product types
add_filter('product_type_selector', [$this, 'addProductType']); add_filter('product_type_selector', [$this, 'addProductType']);
add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 4); add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 4);
@@ -74,6 +77,15 @@ final class LicensedProductType
add_action('admin_footer', [$this, 'addVariableProductScripts']); add_action('admin_footer', [$this, 'addVariableProductScripts']);
} }
/**
* Ensure product type terms exist in the product_type taxonomy
* This is required for WC_Product_Factory::get_product_type() to work correctly
*/
public function ensureProductTypeTermsExist(): void
{
\Jeremias\WcLicensedProduct\Installer::registerProductTypes();
}
/** /**
* Add product types to selector * Add product types to selector
*/ */
@@ -129,15 +141,23 @@ final class LicensedProductType
/** /**
* Add product data tab for license settings * Add product data tab for license settings
* Also modify variations tab to show for licensed-variable products
*/ */
public function addProductDataTab(array $tabs): array public function addProductDataTab(array $tabs): array
{ {
// Add our License Settings tab
$tabs['licensed_product'] = [ $tabs['licensed_product'] = [
'label' => __('License Settings', 'wc-licensed-product'), 'label' => __('License Settings', 'wc-licensed-product'),
'target' => 'licensed_product_data', 'target' => 'licensed_product_data',
'class' => ['show_if_licensed', 'show_if_licensed-variable'], 'class' => ['show_if_licensed', 'show_if_licensed-variable'],
'priority' => 21, 'priority' => 21,
]; ];
// Make Variations tab also show for licensed-variable products
if (isset($tabs['variations'])) {
$tabs['variations']['class'][] = 'show_if_licensed-variable';
}
return $tabs; return $tabs;
} }
@@ -227,35 +247,6 @@ final class LicensedProductType
?> ?>
</div> </div>
</div> </div>
<script type="text/javascript">
jQuery(document).ready(function($) {
// Show/hide panels based on product type for license settings tab
function toggleLicensedProductOptions() {
var productType = $('#product-type').val();
var isLicensed = productType === 'licensed';
var isLicensedVariable = productType === 'licensed-variable';
if (isLicensed || isLicensedVariable) {
// Show license settings tab
$('.show_if_licensed').show();
$('.show_if_licensed-variable').show();
$('.general_options').show();
$('.pricing').show();
$('.general_tab').show();
} else {
// Hide license settings tab for other product types
$('.show_if_licensed').hide();
$('.show_if_licensed-variable').hide();
}
}
// Initial state on page load
toggleLicensedProductOptions();
// On product type change
$('#product-type').on('change', toggleLicensedProductOptions);
});
</script>
<?php <?php
} }
@@ -642,7 +633,8 @@ final class LicensedProductType
} }
/** /**
* Add JavaScript for licensed-variable product type in admin * Add JavaScript for licensed product types in admin
* Handles visibility of License Settings tab and Product Versions meta box
*/ */
public function addVariableProductScripts(): void public function addVariableProductScripts(): void
{ {
@@ -659,60 +651,63 @@ final class LicensedProductType
?> ?>
<script type="text/javascript"> <script type="text/javascript">
jQuery(document).ready(function($) { jQuery(document).ready(function($) {
// Show/hide panels based on product type // Handle our custom License Settings tab, Product Versions meta box,
function toggleLicensedVariableOptions() { // and show_if_licensed-variable elements
function toggleOurElements() {
var productType = $('#product-type').val(); var productType = $('#product-type').val();
var isLicensed = productType === 'licensed';
var isLicensedVariable = productType === 'licensed-variable';
if (productType === 'licensed-variable') { // License Settings tab - use CSS class for visibility
// Show variable product options var $licenseTab = $('li.licensed_product_options');
if (isLicensed || isLicensedVariable) {
$licenseTab.addClass('wclp-active');
} else {
$licenseTab.removeClass('wclp-active');
// If License Settings panel is active, switch to General tab
if ($('#licensed_product_data').is(':visible')) {
$('li.general_options a').trigger('click');
}
}
// Product Versions meta box
var $metaBox = $('#wc_licensed_product_versions');
if (isLicensed || isLicensedVariable) {
$metaBox.css('display', '');
} else {
$metaBox.css('display', 'none');
}
// Handle show_if_licensed-variable elements (like Variations tab)
// WooCommerce doesn't know about our custom product types
if (isLicensedVariable) {
$('.show_if_licensed-variable').show();
// Also show elements that should be visible for variable products
// since licensed-variable is a variable product type
$('.show_if_variable').show(); $('.show_if_variable').show();
$('.hide_if_variable').hide(); $('.hide_if_variable').hide();
} else {
// Show licensed product options // Let WooCommerce handle show_if_variable elements
$('.show_if_licensed-variable').show(); // We only need to hide our custom class when not licensed-variable
$('.show_if_licensed').show(); // Don't hide show_if_licensed-variable when it's licensed (simple)
if (!isLicensed) {
// Show general and variations tabs $('.show_if_licensed-variable').not('.show_if_licensed').hide();
$('.general_tab').show(); }
$('.variations_tab').show();
$('.variations_options').show();
// Hide shipping tab (virtual products)
$('.shipping_tab').hide();
// Ensure the variations panel can be displayed
$('#variable_product_options').show();
} }
} }
// Initial check // Initial setup - run after WooCommerce has initialized
toggleLicensedVariableOptions(); setTimeout(toggleOurElements, 10);
// On product type change // On product type change - run after WooCommerce has processed
$('#product-type').on('change', function() { $('#product-type').on('change', function() {
// Use setTimeout to let WooCommerce finish its own processing first setTimeout(toggleOurElements, 100);
setTimeout(toggleLicensedVariableOptions, 100);
}); });
// Re-apply after WooCommerce AJAX operations that may reset visibility // Re-apply after WooCommerce AJAX operations
$(document).on('woocommerce_variations_loaded', toggleLicensedVariableOptions); $(document).on('woocommerce_variations_loaded woocommerce_variations_added woocommerce_variations_saved', function() {
$(document).on('woocommerce_variations_added', toggleLicensedVariableOptions); setTimeout(toggleOurElements, 10);
$(document).on('woocommerce_variations_saved', toggleLicensedVariableOptions);
// Handle AJAX complete events for attribute saving
$(document).ajaxComplete(function(event, xhr, settings) {
// Check if this was a product data save or attribute action
if (settings.data && (
settings.data.indexOf('action=woocommerce_save_attributes') !== -1 ||
settings.data.indexOf('action=woocommerce_load_variations') !== -1 ||
settings.data.indexOf('action=woocommerce_add_variation') !== -1
)) {
setTimeout(toggleLicensedVariableOptions, 100);
}
}); });
// Also listen for the WooCommerce product type show/hide trigger
$('body').on('woocommerce-product-type-change', toggleLicensedVariableOptions);
}); });
</script> </script>
<?php <?php

View File

@@ -0,0 +1,439 @@
<?php
/**
* Plugin Update Checker
*
* Checks for plugin updates from the configured license server.
*
* @package Jeremias\WcLicensedProduct\Update
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Update;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
use Symfony\Component\HttpClient\HttpClient;
/**
* Handles checking for plugin updates from the license server
*
* This class hooks into WordPress's native plugin update system to check for
* updates from the configured license server. It validates the license and
* provides download authentication.
*/
final class PluginUpdateChecker
{
/**
* Cache key for update info
*/
private const CACHE_KEY = 'wclp_update_info';
/**
* Default cache TTL (12 hours)
*/
private const DEFAULT_CACHE_TTL = 43200;
/**
* Singleton instance
*/
private static ?self $instance = null;
/**
* Plugin slug
*/
private string $pluginSlug;
/**
* Plugin basename (slug/slug.php)
*/
private string $pluginBasename;
/**
* Get singleton instance
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Private constructor for singleton
*/
private function __construct()
{
$this->pluginSlug = 'wc-licensed-product';
$this->pluginBasename = WC_LICENSED_PRODUCT_PLUGIN_BASENAME;
}
/**
* Register WordPress hooks for update checking
*/
public function register(): void
{
// Skip if update notifications are disabled
if ($this->isUpdateNotificationDisabled()) {
return;
}
// Check for updates
add_filter('pre_set_site_transient_update_plugins', [$this, 'checkForUpdates']);
// Provide plugin information for the update modal
add_filter('plugins_api', [$this, 'getPluginInfo'], 10, 3);
// Add authentication headers to download requests
add_filter('http_request_args', [$this, 'addAuthHeaders'], 10, 2);
// Handle auto-install setting
add_filter('auto_update_plugin', [$this, 'handleAutoInstall'], 10, 2);
// Clear cache on settings save
add_action('update_option_wc_licensed_product_plugin_license_key', [$this, 'clearCache']);
add_action('update_option_wc_licensed_product_plugin_license_server_url', [$this, 'clearCache']);
add_action('update_option_wc_licensed_product_update_notification_enabled', [$this, 'clearCache']);
}
/**
* Check if update notifications are disabled
*/
private function isUpdateNotificationDisabled(): bool
{
// Check constant
if (defined('WC_LICENSE_DISABLE_AUTO_UPDATE') && WC_LICENSE_DISABLE_AUTO_UPDATE) {
return true;
}
// Check setting
return !SettingsController::isUpdateNotificationEnabled();
}
/**
* Handle auto-install setting for WordPress automatic updates
*
* @param bool|null $update The update decision
* @param object $item The plugin update object
* @return bool|null Whether to auto-update this plugin
*/
public function handleAutoInstall($update, $item): ?bool
{
// Only handle our plugin
if (!isset($item->plugin) || $item->plugin !== $this->pluginBasename) {
return $update;
}
// Return true to enable auto-install, false to disable, or null to use default
return SettingsController::isAutoInstallEnabled() ? true : $update;
}
/**
* Check for plugin updates
*
* @param object $transient The update_plugins transient
* @return object Modified transient
*/
public function checkForUpdates($transient)
{
if (empty($transient->checked)) {
return $transient;
}
// Get cached update info or fetch fresh
$updateInfo = $this->getUpdateInfo();
if (!$updateInfo || !isset($updateInfo['update_available']) || !$updateInfo['update_available']) {
return $transient;
}
// Compare versions
$currentVersion = $transient->checked[$this->pluginBasename] ?? WC_LICENSED_PRODUCT_VERSION;
if (version_compare($updateInfo['version'], $currentVersion, '>')) {
$transient->response[$this->pluginBasename] = $this->buildUpdateObject($updateInfo);
}
return $transient;
}
/**
* Get plugin information for the update modal
*
* @param false|object|array $result The result object or array
* @param string $action The API action
* @param object $args Request arguments
* @return false|object
*/
public function getPluginInfo($result, string $action, object $args)
{
if ($action !== 'plugin_information') {
return $result;
}
if (!isset($args->slug) || $args->slug !== $this->pluginSlug) {
return $result;
}
// Get update info
$updateInfo = $this->getUpdateInfo(true);
if (!$updateInfo) {
return $result;
}
return $this->buildPluginInfoObject($updateInfo);
}
/**
* Add authentication headers to download requests
*
* @param array $args HTTP request arguments
* @param string $url Request URL
* @return array Modified arguments
*/
public function addAuthHeaders(array $args, string $url): array
{
// Only modify requests to our license server
$serverUrl = $this->getLicenseServerUrl();
if (empty($serverUrl) || strpos($url, parse_url($serverUrl, PHP_URL_HOST)) === false) {
return $args;
}
// Only modify download requests
if (strpos($url, 'license-download') === false) {
return $args;
}
// Add license key to headers for potential server-side verification
$licenseKey = $this->getLicenseKey();
if (!empty($licenseKey)) {
$args['headers']['X-License-Key'] = $licenseKey;
}
return $args;
}
/**
* Get update info from cache or server
*
* @param bool $forceRefresh Force refresh from server
* @return array|null Update info or null if unavailable
*/
public function getUpdateInfo(bool $forceRefresh = false): ?array
{
// Check cache unless force refresh
if (!$forceRefresh) {
$cached = get_transient(self::CACHE_KEY);
if ($cached !== false) {
return $cached;
}
}
// Fetch from server
$updateInfo = $this->fetchUpdateInfo();
if ($updateInfo) {
// Cache the result
$cacheTtl = $this->getCacheTtl();
set_transient(self::CACHE_KEY, $updateInfo, $cacheTtl);
}
return $updateInfo;
}
/**
* Fetch update info from the license server
*/
private function fetchUpdateInfo(): ?array
{
$serverUrl = $this->getLicenseServerUrl();
$licenseKey = $this->getLicenseKey();
if (empty($serverUrl) || empty($licenseKey)) {
return null;
}
try {
$httpClient = HttpClient::create([
'timeout' => 15,
'verify_peer' => true,
]);
$updateCheckUrl = rtrim($serverUrl, '/') . '/wp-json/wc-licensed-product/v1/update-check';
$response = $httpClient->request('POST', $updateCheckUrl, [
'json' => [
'license_key' => $licenseKey,
'domain' => $this->getCurrentDomain(),
'plugin_slug' => $this->pluginSlug,
'current_version' => WC_LICENSED_PRODUCT_VERSION,
],
]);
if ($response->getStatusCode() !== 200) {
return null;
}
$data = $response->toArray();
// Verify response structure
if (!isset($data['success']) || !$data['success']) {
return null;
}
return $data;
} catch (\Throwable $e) {
// Log error but don't break the site
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('WC Licensed Product: Update check failed - ' . $e->getMessage());
}
return null;
}
}
/**
* Build WordPress update object for transient
*/
private function buildUpdateObject(array $updateInfo): object
{
$update = new \stdClass();
$update->id = $this->pluginSlug;
$update->slug = $updateInfo['slug'] ?? $this->pluginSlug;
$update->plugin = $this->pluginBasename;
$update->new_version = $updateInfo['version'];
$update->url = $updateInfo['homepage'] ?? '';
$update->package = $updateInfo['download_url'] ?? $updateInfo['package'] ?? '';
if (isset($updateInfo['tested'])) {
$update->tested = $updateInfo['tested'];
}
if (isset($updateInfo['requires'])) {
$update->requires = $updateInfo['requires'];
}
if (isset($updateInfo['requires_php'])) {
$update->requires_php = $updateInfo['requires_php'];
}
if (isset($updateInfo['icons'])) {
$update->icons = $updateInfo['icons'];
}
return $update;
}
/**
* Build plugin info object for plugins_api
*/
private function buildPluginInfoObject(array $updateInfo): object
{
$info = new \stdClass();
$info->name = $updateInfo['name'] ?? 'WC Licensed Product';
$info->slug = $updateInfo['slug'] ?? $this->pluginSlug;
$info->version = $updateInfo['version'];
$info->author = '<a href="https://src.bundespruefstelle.ch/magdev">Marco Graetsch</a>';
$info->homepage = $updateInfo['homepage'] ?? '';
$info->requires = $updateInfo['requires'] ?? '6.0';
$info->tested = $updateInfo['tested'] ?? '';
$info->requires_php = $updateInfo['requires_php'] ?? '8.3';
$info->downloaded = 0;
$info->last_updated = $updateInfo['last_updated'] ?? '';
$info->download_link = $updateInfo['download_url'] ?? $updateInfo['package'] ?? '';
// Sections for the modal
$info->sections = [];
if (isset($updateInfo['sections']['description'])) {
$info->sections['description'] = $updateInfo['sections']['description'];
} else {
$info->sections['description'] = __(
'WooCommerce plugin for selling licensed software products with domain-bound license keys.',
'wc-licensed-product'
);
}
if (isset($updateInfo['sections']['changelog']) || isset($updateInfo['changelog'])) {
$info->sections['changelog'] = $updateInfo['sections']['changelog'] ?? $updateInfo['changelog'];
}
// Banners and icons
if (isset($updateInfo['banners'])) {
$info->banners = $updateInfo['banners'];
}
if (isset($updateInfo['icons'])) {
$info->icons = $updateInfo['icons'];
}
return $info;
}
/**
* Clear the update cache
*/
public function clearCache(): void
{
delete_transient(self::CACHE_KEY);
}
/**
* Get cache TTL from settings or default
*/
private function getCacheTtl(): int
{
$hours = (int) get_option('wc_licensed_product_update_check_frequency', 12);
return max(1, $hours) * HOUR_IN_SECONDS;
}
/**
* Get the license server URL from settings
*/
private function getLicenseServerUrl(): string
{
// Check constant override first
if (defined('WC_LICENSE_UPDATE_CHECK_URL') && WC_LICENSE_UPDATE_CHECK_URL) {
return WC_LICENSE_UPDATE_CHECK_URL;
}
return (string) get_option('wc_licensed_product_plugin_license_server_url', '');
}
/**
* Get the license key from settings
*/
private function getLicenseKey(): string
{
return (string) get_option('wc_licensed_product_plugin_license_key', '');
}
/**
* Get the current domain from the site URL
*/
private function getCurrentDomain(): string
{
$siteUrl = get_site_url();
$parsed = parse_url($siteUrl);
$host = $parsed['host'] ?? 'localhost';
if (isset($parsed['port'])) {
$host .= ':' . $parsed['port'];
}
return strtolower($host);
}
/**
* Force an immediate update check
*
* Useful for admin interfaces where user clicks "Check for updates"
*/
public function forceUpdateCheck(): ?array
{
$this->clearCache();
return $this->getUpdateInfo(true);
}
}

View File

@@ -424,12 +424,11 @@
if (result.valid) { if (result.valid) {
html = '<div class="notice notice-success inline"><p><strong>✓ {{ __('License is VALID') }}</strong></p></div>'; html = '<div class="notice notice-success inline"><p><strong>✓ {{ __('License is VALID') }}</strong></p></div>';
html += '<table class="widefat striped"><tbody>'; html += '<table class="widefat striped"><tbody>';
html += '<tr><th>{{ __('Product') }}</th><td>' + escapeHtml(result.product_name || '-') + '</td></tr>'; html += '<tr><th>{{ __('Product') }}</th><td><strong>' + escapeHtml(result.product_name || '-') + '</strong></td></tr>';
html += '<tr><th>{{ __('Version') }}</th><td>' + escapeHtml(result.version || '-') + '</td></tr>';
if (result.expires_at) { if (result.expires_at) {
html += '<tr><th>{{ __('Expires') }}</th><td>' + escapeHtml(result.expires_at) + '</td></tr>'; html += '<tr><th>{{ __('Expires') }}</th><td>' + escapeHtml(result.expires_at) + '</td></tr>';
} else { } else {
html += '<tr><th>{{ __('Expires') }}</th><td>{{ __('Lifetime') }}</td></tr>'; html += '<tr><th>{{ __('Expires') }}</th><td><span class="license-lifetime">{{ __('Lifetime') }}</span></td></tr>';
} }
html += '</tbody></table>'; html += '</tbody></table>';
} else { } else {

View File

@@ -1,5 +1,53 @@
{# License Filter Form #}
{% if filter_products is defined and filter_products|length > 0 or filter_domains is defined and filter_domains|length > 0 %}
<div class="wclp-filter-form">
<form method="get" action="{{ esc_url(licenses_url) }}">
<div class="wclp-filter-row">
{% if filter_products is defined and filter_products|length > 0 %}
<div class="wclp-filter-field">
<label for="filter_product">{{ __('Product') }}</label>
<select name="filter_product" id="filter_product">
<option value="">{{ __('All Products') }}</option>
{% for id, name in filter_products %}
<option value="{{ id }}" {{ current_filter_product == id ? 'selected' : '' }}>
{{ esc_html(name) }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if filter_domains is defined and filter_domains|length > 0 %}
<div class="wclp-filter-field">
<label for="filter_domain">{{ __('Domain') }}</label>
<select name="filter_domain" id="filter_domain">
<option value="">{{ __('All Domains') }}</option>
{% for domain in filter_domains %}
<option value="{{ esc_attr(domain) }}" {{ current_filter_domain == domain ? 'selected' : '' }}>
{{ esc_html(domain) }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="wclp-filter-actions">
<button type="submit" class="button">{{ __('Filter') }}</button>
{% if is_filtered %}
<a href="{{ esc_url(licenses_url) }}" class="button">{{ __('Clear') }}</a>
{% endif %}
</div>
</div>
</form>
</div>
{% endif %}
{% if not has_packages %} {% if not has_packages %}
<p>{{ __('You have no licenses yet.') }}</p> {% if is_filtered %}
<p>{{ __('No licenses found matching your filters.') }}</p>
{% else %}
<p>{{ __('You have no licenses yet.') }}</p>
{% endif %}
{% else %} {% else %}
<div class="woocommerce-licenses"> <div class="woocommerce-licenses">
{% for package in packages %} {% for package in packages %}

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce Licensed Product * Plugin Name: WooCommerce Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation. * Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
* Version: 0.5.12 * Version: 0.6.1
* Author: Marco Graetsch * Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev * Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
} }
// Plugin constants // Plugin constants
define('WC_LICENSED_PRODUCT_VERSION', '0.5.12'); define('WC_LICENSED_PRODUCT_VERSION', '0.6.1');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__); define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));