7 Commits

Author SHA1 Message Date
086755cb11 Update translations for v0.5.5
Regenerated .pot template and recompiled German translations.
All 391 strings translated.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 17:07:36 +01:00
0b58de193e Fix critical signature compatibility with client library (v0.5.5)
CRITICAL: Key derivation now uses native hash_hkdf() for RFC 5869
compliance. Previous custom implementation was incompatible with
the magdev/wc-licensed-product-client library.

Changes:
- ResponseSigner::deriveCustomerSecret() now uses hash_hkdf()
- Added missing domain validation to /activate endpoint
- Customer secrets will change after upgrade (breaking change)

The signature algorithm now matches the client's ResponseSignature::deriveKey():
- IKM: server_secret
- Length: 32 bytes
- Info: license_key

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 17:06:18 +01:00
ae49b262fa Update wc-licensed-product-client dependency
Updated magdev/wc-licensed-product-client from 64d215c to 5e4b5a9.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 17:02:03 +01:00
5d5bb7e595 Align REST API with client documentation (v0.5.4)
Fixed HTTP status codes for API responses:
- /validate now returns 404 for license_not_found (was 403)
- Added status code mapping: 404 not found, 500 server errors, 403 others

Added configurable rate limiting:
- WC_LICENSE_RATE_LIMIT constant for requests per window
- WC_LICENSE_RATE_WINDOW constant for window duration in seconds

Fixed license_key validation:
- Now enforces minimum 8 characters across all endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 17:00:52 +01:00
bee9854c18 Add release package v0.5.3
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:15:51 +01:00
c31df1e8c4 Add licensed variable product support for duration-based licenses (v0.5.3)
Customers can now purchase licenses with different durations (monthly,
yearly, lifetime) through WooCommerce product variations. Each variation
can have its own license validity settings.

New features:
- LicensedVariableProduct class for variable licensed products
- LicensedProductVariation class for individual variations
- Per-variation license duration and max activations settings
- Duration labels in checkout (Monthly, Quarterly, Yearly, etc.)
- Full support for WooCommerce Blocks checkout with variations
- Updated translations for German (de_CH)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:14:15 +01:00
8cac742f57 Update CLAUDE.md with v0.5.2 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:36:35 +01:00
20 changed files with 2474 additions and 1218 deletions

View File

@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.5.5] - 2026-01-26
### Fixed
- **CRITICAL:** Response signing key derivation now uses native `hash_hkdf()` for RFC 5869 compliance
- Key derivation now matches client library (`SecureLicenseClient`) exactly
- Added missing domain validation to `/activate` endpoint (1-255 characters)
### Changed
- `ResponseSigner::deriveCustomerSecret()` now uses `hash_hkdf('sha256', $serverSecret, 32, $licenseKey)`
- Previous custom HKDF-like implementation was incompatible with client library
### Security
- Signatures generated by server now verify correctly with `magdev/wc-licensed-product-client`
- All three API endpoints now have consistent parameter validation
## [0.5.4] - 2026-01-26
### Fixed
- REST API `/validate` endpoint now returns HTTP 404 for `license_not_found` error (was 403)
- License key validation now enforces minimum 8 characters per API documentation
### Added
- Configurable rate limiting via `WC_LICENSE_RATE_LIMIT` and `WC_LICENSE_RATE_WINDOW` constants
- Rate limit now defaults to 30 requests per 60 second window (configurable)
### Changed
- Improved HTTP status code mapping: 404 for not found, 500 for server errors, 403 for all other errors
- Rate limiting implementation now uses configurable constants instead of hardcoded values
## [0.5.3] - 2026-01-26
### Added
- Variable licensed product type (`licensed-variable`) for selling licenses with different durations
- Support for monthly, yearly, quarterly, or lifetime license variations
- `LicensedVariableProduct` class extending `WC_Product_Variable`
- `LicensedProductVariation` class for individual variation license settings
- Variation-specific license duration settings in product edit page
- Duration labels displayed in checkout domain fields (e.g., "Yearly License")
- Variation ID tracking in order domain meta for proper license generation
### Changed
- Updated `LicenseManager::generateLicense()` to accept optional variation ID
- Checkout now handles variations with separate domain fields per product/variation
- WooCommerce Blocks checkout updated to display variation duration labels
- Store API extension updated to include variation_id in domain data schema
## [0.5.2] - 2026-01-26 ## [0.5.2] - 2026-01-26
### Added ### Added

152
CLAUDE.md
View File

@@ -32,10 +32,6 @@ 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.5.2
*No planned bugfixes yet.*
### Version 0.6.0 ### Version 0.6.0
*No planned features yet.* *No planned features yet.*
@@ -1293,3 +1289,151 @@ Bug fix release improving admin UI usability for version management and license
- Created release package: `releases/wc-licensed-product-0.5.1.zip` (863 KB) - Created release package: `releases/wc-licensed-product-0.5.1.zip` (863 KB)
- SHA256: `a489f0b8cfcd7d5d9b2021b7ff581b9f1a56468dfde87bbb06bb4555d11f7556` - SHA256: `a489f0b8cfcd7d5d9b2021b7ff581b9f1a56468dfde87bbb06bb4555d11f7556`
- Tagged as `v0.5.1` and pushed to `main` branch - Tagged as `v0.5.1` and pushed to `main` branch
### 2026-01-26 - Version 0.5.2 - Per-License Customer Secrets
**Overview:**
Security enhancement release adding per-license customer secrets for API response verification. Each customer now receives a unique secret derived from their license key, eliminating the need to share a global server secret.
**Implemented:**
- Per-license secret derivation using HKDF-like approach
- Customer account UI showing API verification secret with collapsible section
- Copy-to-clipboard functionality for customer secrets
- Static helper methods in ResponseSigner for secret derivation
**New methods in ResponseSigner:**
- `deriveCustomerSecret()` - Static method to derive customer secret from license key and server secret
- `getCustomerSecretForLicense()` - Static method to get customer secret using configured server secret
- `isSigningEnabled()` - Static method to check if response signing is configured
**Modified files:**
- `src/Api/ResponseSigner.php` - Added static methods for customer secret derivation
- `src/Frontend/AccountController.php` - Added `signing_enabled` and `customer_secret` to template data
- `templates/frontend/licenses.html.twig` - Added collapsible secret section with toggle and copy button
- `assets/css/frontend.css` - Added styles for `.license-row-secret`, `.secret-toggle`, `.secret-content`
- `assets/js/frontend.js` - Added `toggleSecret()` and `copySecret()` event handlers
- `docs/server-implementation.md` - Added documentation for per-license secrets
**Technical notes:**
- Secret derivation uses HKDF-like approach: `HMAC-SHA256(HMAC-SHA256(license_key, server_secret) + "\x01", server_secret)`
- Each license gets a unique 64-character hex secret
- Secrets are only shown when `WC_LICENSE_SERVER_SECRET` is configured
- Collapsible UI prevents accidental secret exposure
- If server secret is rotated, all customer secrets change automatically
**Security improvement:**
- Customers no longer need access to the master `WC_LICENSE_SERVER_SECRET`
- If one customer's secret is leaked, other customers are not affected
- Each license key derives its own unique verification secret
**Release v0.5.2:**
- Created release package: `releases/wc-licensed-product-0.5.2.zip` (845 KB)
- SHA256: `2d61a78ac5ba0f1d115a6401e6dded5b872b18f5530027c371604cbd18e9e27c`
- Tagged as `v0.5.2` and pushed to `main` branch
### 2026-01-26 - Version 0.5.3 - Variable Licensed Products
**Overview:**
Major feature release adding support for WooCommerce variable products. Customers can now purchase licenses with different durations (monthly, yearly, lifetime) as product variations.
**New files:**
- `src/Product/LicensedVariableProduct.php` - Variable product class extending `WC_Product_Variable`
- `src/Product/LicensedProductVariation.php` - Variation class with license settings
**Implemented:**
- New `licensed-variable` product type for selling licenses with different durations
- `LicensedVariableProduct` class extending WooCommerce variable products
- `LicensedProductVariation` class for individual variation license settings
- Variation-specific license duration fields in product edit page (days, max activations)
- Duration labels (Monthly, Quarterly, Yearly, Lifetime) displayed in checkout
- Variation ID tracking in order domain meta for proper license generation
- WooCommerce Blocks checkout updated to handle variations with duration labels
**Modified files:**
- `src/Product/LicensedProductType.php` - Added licensed-variable type registration, variation hooks
- `src/License/LicenseManager.php` - Added `isLicensedProduct()` helper, variation support in `generateLicense()`
- `src/Plugin.php` - Updated license generation to handle variations
- `src/Checkout/CheckoutController.php` - Variation support in domain field rendering
- `src/Checkout/CheckoutBlocksIntegration.php` - Variation data in blocks checkout
- `src/Checkout/StoreApiExtension.php` - Variation ID in Store API schema
- `assets/js/checkout-blocks.js` - Variation handling in React components and DOM fallback
**Technical notes:**
- Variable product type shows in WooCommerce product type selector as "Licensed Variable Product"
- Each variation can override parent's license duration and max activations
- Variations are always virtual (licensed products don't ship)
- `LicensedProductVariation::get_license_duration_label()` returns human-readable duration
- Order meta `_licensed_product_domains` now includes optional `variation_id` field
- License generation uses variation settings when `variation_id` is present in order item
- Backward compatible: existing simple licensed products continue to work unchanged
### 2026-01-26 - Version 0.5.4 - API Compliance
**Overview:**
Bug fix release aligning server implementation with client documentation at `magdev/wc-licensed-product-client`.
**Fixed:**
- `/validate` endpoint now returns HTTP 404 for `license_not_found` error (was returning 403)
- License key validation now enforces minimum 8 characters across all API endpoints
**Implemented:**
- Configurable rate limiting via `WC_LICENSE_RATE_LIMIT` constant (default: 30 requests)
- Configurable rate window via `WC_LICENSE_RATE_WINDOW` constant (default: 60 seconds)
- HTTP status code mapping: 404 for not found, 500 for server errors, 403 for all other errors
**Modified files:**
- `src/Api/RestApiController.php` - Added configurable rate limiting, fixed HTTP status codes, added license_key validation
**Technical notes:**
- Rate limiting now uses `getRateLimit()` and `getRateWindow()` methods instead of constants
- New `getStatusCodeForResult()` method maps error codes to HTTP status codes
- License key validation callback added to all three endpoints (validate, status, activate)
- Uses PHP 8 match expression for status code mapping
### 2026-01-26 - Version 0.5.5 - Critical Signature Fix
**Overview:**
Critical bug fix for response signing. The key derivation algorithm was incompatible with the client library, causing signature verification failures.
**Critical Fix:**
- Key derivation now uses PHP's native `hash_hkdf()` function per RFC 5869
- Previous custom implementation produced different keys than the client library
- Signatures now verify correctly with `magdev/wc-licensed-product-client`
**Additional Fix:**
- Added missing domain validation to `/activate` endpoint (1-255 characters)
**Modified files:**
- `src/Api/ResponseSigner.php` - Fixed key derivation to use `hash_hkdf()`
- `src/Api/RestApiController.php` - Added domain validation to `/activate` endpoint
**Technical notes:**
- Old implementation: `hash_hmac('sha256', $prk . "\x01", $serverSecret)` - custom HKDF-like
- New implementation: `bin2hex(hash_hkdf('sha256', $serverSecret, 32, $licenseKey))` - RFC 5869
- Parameters match client's `ResponseSignature::deriveKey()` exactly:
- IKM (input keying material): server_secret
- Length: 32 bytes (256 bits)
- Info: license_key (context-specific info)
- **Breaking change for existing signatures** - customer secrets will change after upgrade

View File

@@ -110,6 +110,16 @@
); );
}; };
/**
* Get unique key for product (handles variations)
*/
function getProductKey(product) {
if (product.variation_id && product.variation_id > 0) {
return `${product.product_id}_${product.variation_id}`;
}
return String(product.product_id);
}
/** /**
* Multi-Domain Component * Multi-Domain Component
*/ */
@@ -118,7 +128,8 @@
const [domains, setDomains] = useState(() => { const [domains, setDomains] = useState(() => {
const init = {}; const init = {};
products.forEach(p => { products.forEach(p => {
init[p.product_id] = Array(p.quantity).fill(''); const key = getProductKey(p);
init[key] = Array(p.quantity).fill('');
}); });
return init; return init;
}); });
@@ -128,16 +139,16 @@
return null; return null;
} }
const handleChange = (productId, index, value) => { const handleChange = (productKey, index, value) => {
const normalized = normalizeDomain(value); const normalized = normalizeDomain(value);
const newDomains = { ...domains }; const newDomains = { ...domains };
if (!newDomains[productId]) newDomains[productId] = []; if (!newDomains[productKey]) newDomains[productKey] = [];
newDomains[productId] = [...newDomains[productId]]; newDomains[productKey] = [...newDomains[productKey]];
newDomains[productId][index] = normalized; newDomains[productKey][index] = normalized;
setDomains(newDomains); setDomains(newDomains);
// Validate // Validate
const key = `${productId}_${index}`; const key = `${productKey}_${index}`;
const newErrors = { ...errors }; const newErrors = { ...errors };
if (normalized && !isValidDomain(normalized)) { if (normalized && !isValidDomain(normalized)) {
newErrors[key] = settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product'); newErrors[key] = settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product');
@@ -145,14 +156,14 @@
delete newErrors[key]; delete newErrors[key];
} }
// Check for duplicates within same product // Check for duplicates within same product/variation
const productDomains = newDomains[productId].filter(d => d); const productDomains = newDomains[productKey].filter(d => d);
const uniqueDomains = new Set(productDomains.map(d => normalizeDomain(d))); const uniqueDomains = new Set(productDomains.map(d => normalizeDomain(d)));
if (productDomains.length !== uniqueDomains.size) { if (productDomains.length !== uniqueDomains.size) {
const seen = new Set(); const seen = new Set();
newDomains[productId].forEach((d, idx) => { newDomains[productKey].forEach((d, idx) => {
const normalizedD = normalizeDomain(d); const normalizedD = normalizeDomain(d);
const dupKey = `${productId}_${idx}`; const dupKey = `${productKey}_${idx}`;
if (normalizedD && seen.has(normalizedD)) { if (normalizedD && seen.has(normalizedD)) {
newErrors[dupKey] = settings.duplicateError || __('Each license requires a unique domain.', 'wc-licensed-product'); newErrors[dupKey] = settings.duplicateError || __('Each license requires a unique domain.', 'wc-licensed-product');
} else if (normalizedD) { } else if (normalizedD) {
@@ -163,11 +174,19 @@
setErrors(newErrors); setErrors(newErrors);
// Update hidden field // Update hidden field with variation support
const data = Object.entries(newDomains).map(([pid, doms]) => ({ const data = products.map(p => {
product_id: parseInt(pid, 10), const pKey = getProductKey(p);
const doms = newDomains[pKey] || [];
const entry = {
product_id: p.product_id,
domains: doms.filter(d => d), domains: doms.filter(d => d),
})).filter(item => item.domains.length > 0); };
if (p.variation_id && p.variation_id > 0) {
entry.variation_id = p.variation_id;
}
return entry;
}).filter(item => item.domains.length > 0);
const hiddenInput = document.getElementById('wclp-domains-hidden'); const hiddenInput = document.getElementById('wclp-domains-hidden');
if (hiddenInput) { if (hiddenInput) {
@@ -192,10 +211,17 @@
createElement('p', { style: { marginBottom: '12px', color: '#666', fontSize: '0.9em' } }, createElement('p', { style: { marginBottom: '12px', color: '#666', fontSize: '0.9em' } },
settings.fieldDescription || __('Enter a unique domain for each license.', 'wc-licensed-product') settings.fieldDescription || __('Enter a unique domain for each license.', 'wc-licensed-product')
), ),
products.map(product => createElement( products.map(product => {
const productKey = getProductKey(product);
const durationLabel = product.duration_label || '';
const displayName = durationLabel
? `${product.name} (${durationLabel})`
: product.name;
return createElement(
'div', 'div',
{ {
key: product.product_id, key: productKey,
style: { style: {
marginBottom: '16px', marginBottom: '16px',
padding: '12px', padding: '12px',
@@ -204,23 +230,24 @@
} }
}, },
createElement('strong', { style: { display: 'block', marginBottom: '8px' } }, createElement('strong', { style: { display: 'block', marginBottom: '8px' } },
product.name + (product.quantity > 1 ? ` (×${product.quantity})` : '') displayName + (product.quantity > 1 ? ` ×${product.quantity}` : '')
), ),
Array.from({ length: product.quantity }, (_, i) => { Array.from({ length: product.quantity }, (_, i) => {
const key = `${product.product_id}_${i}`; const key = `${productKey}_${i}`;
return createElement( return createElement(
'div', 'div',
{ key: i, style: { marginBottom: '8px' } }, { key: i, style: { marginBottom: '8px' } },
createElement(TextControl, { createElement(TextControl, {
label: (settings.licenseLabel || __('License %d:', 'wc-licensed-product')).replace('%d', i + 1), label: (settings.licenseLabel || __('License %d:', 'wc-licensed-product')).replace('%d', i + 1),
value: domains[product.product_id]?.[i] || '', value: domains[productKey]?.[i] || '',
onChange: (val) => handleChange(product.product_id, i, val), onChange: (val) => handleChange(productKey, i, val),
placeholder: settings.fieldPlaceholder || 'example.com', placeholder: settings.fieldPlaceholder || 'example.com',
help: errors[key] || '', help: errors[key] || '',
}) })
); );
}) })
)), );
}),
createElement('input', { createElement('input', {
type: 'hidden', type: 'hidden',
id: 'wclp-domains-hidden', id: 'wclp-domains-hidden',
@@ -291,10 +318,19 @@
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;"> <p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
${settings.fieldDescription || 'Enter a unique domain for each license.'} ${settings.fieldDescription || 'Enter a unique domain for each license.'}
</p> </p>
${settings.licensedProducts.map(product => ` ${settings.licensedProducts.map(product => {
const productKey = product.variation_id && product.variation_id > 0
? `${product.product_id}_${product.variation_id}`
: product.product_id;
const durationLabel = product.duration_label || '';
const displayName = durationLabel
? `${product.name} (${durationLabel})`
: product.name;
return `
<div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;"> <div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;">
<strong style="display: block; margin-bottom: 8px;"> <strong style="display: block; margin-bottom: 8px;">
${product.name}${product.quantity > 1 ? ` (×${product.quantity})` : ''} ${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''}
</strong> </strong>
${Array.from({ length: product.quantity }, (_, i) => ` ${Array.from({ length: product.quantity }, (_, i) => `
<div style="margin-bottom: 8px;"> <div style="margin-bottom: 8px;">
@@ -302,14 +338,20 @@
${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)} ${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)}
</label> </label>
<input type="text" <input type="text"
name="licensed_domains[${product.product_id}][${i}]" name="licensed_domains[${productKey}][${i}]"
placeholder="${settings.fieldPlaceholder || 'example.com'}" placeholder="${settings.fieldPlaceholder || 'example.com'}"
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
/> />
${product.variation_id && product.variation_id > 0 ? `
<input type="hidden"
name="licensed_variation_ids[${productKey}]"
value="${product.variation_id}"
/>
` : ''}
</div> </div>
`).join('')} `).join('')}
</div> </div>
`).join('')} `}).join('')}
`; `;
} else { } else {
container.innerHTML = ` container.innerHTML = `

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": "64d215cb265a64ff318cfbb954dd128b0076dc1d" "reference": "5e4b5a970f75d0163c5496581d963a24ade4f276"
}, },
"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-24T13:32:11+00:00" "time": "2026-01-26T15:54:37+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

Binary file not shown.

View File

@@ -0,0 +1 @@
bbd0fa8888c6990a4ba00ccfb8b2189ee6ac529a34cc11a5d8d8d28518b1f6dd wc-licensed-product-0.5.3.zip

View File

@@ -157,16 +157,23 @@ final class ResponseSigner
* to verify signed API responses. Each customer gets their own secret * to verify signed API responses. Each customer gets their own secret
* derived from their license key. * derived from their license key.
* *
* Uses RFC 5869 HKDF via PHP's native hash_hkdf() function.
* Parameters match the client library (SecureLicenseClient):
* - IKM (input keying material): server_secret
* - Length: 32 bytes (256 bits for SHA-256)
* - Info: license_key (context-specific info)
*
* @param string $licenseKey The customer's license key * @param string $licenseKey The customer's license key
* @param string $serverSecret The server's master secret * @param string $serverSecret The server's master secret
* @return string The derived secret (64 hex characters) * @return string The derived secret (64 hex characters)
*/ */
public static function deriveCustomerSecret(string $licenseKey, string $serverSecret): string public static function deriveCustomerSecret(string $licenseKey, string $serverSecret): string
{ {
// HKDF-like key derivation // RFC 5869 HKDF using PHP's native implementation
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true); // Must match client's ResponseSignature::deriveKey() exactly
$binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey);
return hash_hmac('sha256', $prk . "\x01", $serverSecret); return bin2hex($binaryKey);
} }
/** /**

View File

@@ -22,14 +22,34 @@ final class RestApiController
private const NAMESPACE = 'wc-licensed-product/v1'; private const NAMESPACE = 'wc-licensed-product/v1';
/** /**
* Rate limit: requests per minute per IP * Default rate limit: requests per window per IP
*/ */
private const RATE_LIMIT_REQUESTS = 30; private const DEFAULT_RATE_LIMIT = 30;
/** /**
* Rate limit window in seconds * Default rate limit window in seconds
*/ */
private const RATE_LIMIT_WINDOW = 60; private const DEFAULT_RATE_WINDOW = 60;
/**
* 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;
}
private LicenseManager $licenseManager; private LicenseManager $licenseManager;
@@ -56,12 +76,14 @@ final class RestApiController
{ {
$ip = $this->getClientIp(); $ip = $this->getClientIp();
$transientKey = 'wclp_rate_' . md5($ip); $transientKey = 'wclp_rate_' . md5($ip);
$rateLimit = $this->getRateLimit();
$rateWindow = $this->getRateWindow();
$data = get_transient($transientKey); $data = get_transient($transientKey);
if ($data === false) { if ($data === false) {
// First request, start counting // First request, start counting
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW); set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow);
return null; return null;
} }
@@ -69,15 +91,15 @@ final class RestApiController
$start = (int) ($data['start'] ?? time()); $start = (int) ($data['start'] ?? time());
// Check if window has expired // Check if window has expired
if (time() - $start >= self::RATE_LIMIT_WINDOW) { if (time() - $start >= $rateWindow) {
// Reset counter // Reset counter
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW); set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow);
return null; return null;
} }
// Check if limit exceeded // Check if limit exceeded
if ($count >= self::RATE_LIMIT_REQUESTS) { if ($count >= $rateLimit) {
$retryAfter = self::RATE_LIMIT_WINDOW - (time() - $start); $retryAfter = $rateWindow - (time() - $start);
$response = new WP_REST_Response([ $response = new WP_REST_Response([
'success' => false, 'success' => false,
'error' => 'rate_limit_exceeded', 'error' => 'rate_limit_exceeded',
@@ -89,7 +111,7 @@ final class RestApiController
} }
// Increment counter // Increment counter
set_transient($transientKey, ['count' => $count + 1, 'start' => $start], self::RATE_LIMIT_WINDOW); set_transient($transientKey, ['count' => $count + 1, 'start' => $start], $rateWindow);
return null; return null;
} }
@@ -257,7 +279,8 @@ final class RestApiController
'type' => 'string', 'type' => 'string',
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ($value): bool { 'validate_callback' => function ($value): bool {
return !empty($value) && strlen($value) <= 64; $len = strlen($value);
return !empty($value) && $len >= 8 && $len <= 64;
}, },
], ],
'domain' => [ 'domain' => [
@@ -281,6 +304,10 @@ final class RestApiController
'required' => true, 'required' => true,
'type' => 'string', 'type' => 'string',
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ($value): bool {
$len = strlen($value);
return !empty($value) && $len >= 8 && $len <= 64;
},
], ],
], ],
]); ]);
@@ -295,11 +322,18 @@ final class RestApiController
'required' => true, 'required' => true,
'type' => 'string', 'type' => 'string',
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ($value): bool {
$len = strlen($value);
return !empty($value) && $len >= 8 && $len <= 64;
},
], ],
'domain' => [ 'domain' => [
'required' => true, 'required' => true,
'type' => 'string', 'type' => 'string',
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ($value): bool {
return !empty($value) && strlen($value) <= 255;
},
], ],
], ],
]); ]);
@@ -320,11 +354,32 @@ final class RestApiController
$result = $this->licenseManager->validateLicense($licenseKey, $domain); $result = $this->licenseManager->validateLicense($licenseKey, $domain);
$statusCode = $result['valid'] ? 200 : 403; $statusCode = $this->getStatusCodeForResult($result);
return new WP_REST_Response($result, $statusCode); return new WP_REST_Response($result, $statusCode);
} }
/**
* Get HTTP status code based on validation result
*
* @param array $result The validation result
* @return int HTTP status code
*/
private function getStatusCodeForResult(array $result): int
{
if ($result['valid']) {
return 200;
}
$error = $result['error'] ?? '';
return match ($error) {
'license_not_found' => 404,
'activation_failed' => 500,
default => 403,
};
}
/** /**
* Check license status endpoint * Check license status endpoint
*/ */

View File

@@ -11,6 +11,7 @@ namespace Jeremias\WcLicensedProduct\Checkout;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface; use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface;
use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
/** /**
* Integration with WooCommerce Checkout Blocks * Integration with WooCommerce Checkout Blocks
@@ -141,7 +142,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
/** /**
* Get licensed products from cart with quantities * Get licensed products from cart with quantities
* *
* @return array<int, array{product_id: int, name: string, quantity: int}> * @return array<string, array{product_id: int, variation_id: int, name: string, quantity: int, duration_label: string}>
*/ */
private function getLicensedProductsFromCart(): array private function getLicensedProductsFromCart(): array
{ {
@@ -152,13 +153,49 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
$licensedProducts = []; $licensedProducts = [];
foreach (WC()->cart->get_cart() as $cartItem) { foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data']; $product = $cartItem['data'];
if ($product && $product->is_type('licensed')) { if (!$product) {
continue;
}
// Check for simple licensed products
if ($product->is_type('licensed')) {
$productId = $product->get_id(); $productId = $product->get_id();
$licensedProducts[] = [ $licensedProducts[] = [
'product_id' => $productId, 'product_id' => $productId,
'variation_id' => 0,
'name' => $product->get_name(), 'name' => $product->get_name(),
'quantity' => (int) $cartItem['quantity'], 'quantity' => (int) $cartItem['quantity'],
'duration_label' => '',
]; ];
continue;
}
// Check for variations of licensed-variable products
if ($product->is_type('variation')) {
$parentId = $product->get_parent_id();
$parent = wc_get_product($parentId);
if ($parent && $parent->is_type('licensed-variable')) {
$variationId = $product->get_id();
// Get duration label if it's a LicensedProductVariation
$durationLabel = '';
if ($product instanceof LicensedProductVariation) {
$durationLabel = $product->get_license_duration_label();
} else {
// Try to instantiate as LicensedProductVariation
$variation = new LicensedProductVariation($variationId);
$durationLabel = $variation->get_license_duration_label();
}
$licensedProducts[] = [
'product_id' => $parentId,
'variation_id' => $variationId,
'name' => $product->get_name(),
'quantity' => (int) $cartItem['quantity'],
'duration_label' => $durationLabel,
];
}
} }
} }

View File

@@ -11,6 +11,7 @@ namespace Jeremias\WcLicensedProduct\Checkout;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
/** /**
* Handles checkout modifications for licensed products * Handles checkout modifications for licensed products
@@ -57,7 +58,7 @@ final class CheckoutController
/** /**
* Get licensed products from cart with quantities * Get licensed products from cart with quantities
* *
* @return array<int, array{product_id: int, name: string, quantity: int}> * @return array<string, array{product_id: int, variation_id: int, name: string, quantity: int, duration_label: string}>
*/ */
private function getLicensedProductsFromCart(): array private function getLicensedProductsFromCart(): array
{ {
@@ -68,13 +69,51 @@ final class CheckoutController
$licensedProducts = []; $licensedProducts = [];
foreach (WC()->cart->get_cart() as $cartItem) { foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data']; $product = $cartItem['data'];
if ($product && $product->is_type('licensed')) { if (!$product) {
continue;
}
// Check for simple licensed products
if ($product->is_type('licensed')) {
$productId = $product->get_id(); $productId = $product->get_id();
$licensedProducts[$productId] = [ $licensedProducts[$productId] = [
'product_id' => $productId, 'product_id' => $productId,
'variation_id' => 0,
'name' => $product->get_name(), 'name' => $product->get_name(),
'quantity' => (int) $cartItem['quantity'], 'quantity' => (int) $cartItem['quantity'],
'duration_label' => '',
]; ];
continue;
}
// Check for variations of licensed-variable products
if ($product->is_type('variation')) {
$parentId = $product->get_parent_id();
$parent = wc_get_product($parentId);
if ($parent && $parent->is_type('licensed-variable')) {
$variationId = $product->get_id();
// Use combination key to allow same product with different variations
$key = "{$parentId}_{$variationId}";
// Get duration label if it's a LicensedProductVariation
$durationLabel = '';
if ($product instanceof LicensedProductVariation) {
$durationLabel = $product->get_license_duration_label();
} else {
// Try to instantiate as LicensedProductVariation
$variation = new LicensedProductVariation($variationId);
$durationLabel = $variation->get_license_duration_label();
}
$licensedProducts[$key] = [
'product_id' => $parentId,
'variation_id' => $variationId,
'name' => $product->get_name(),
'quantity' => (int) $cartItem['quantity'],
'duration_label' => $durationLabel,
];
}
} }
} }
@@ -150,22 +189,32 @@ final class CheckoutController
<?php esc_html_e('Enter a unique domain for each license (without http:// or www).', 'wc-licensed-product'); ?> <?php esc_html_e('Enter a unique domain for each license (without http:// or www).', 'wc-licensed-product'); ?>
</p> </p>
<?php foreach ($licensedProducts as $productId => $productData): ?> <?php foreach ($licensedProducts as $key => $productData): ?>
<div class="wclp-product-domains" data-product-id="<?php echo esc_attr($productId); ?>"> <?php
$productId = $productData['product_id'];
$variationId = $productData['variation_id'] ?? 0;
$durationLabel = $productData['duration_label'] ?? '';
// Use key for field names to handle variations
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : $productId;
?>
<div class="wclp-product-domains" data-product-id="<?php echo esc_attr($productId); ?>" data-variation-id="<?php echo esc_attr($variationId); ?>">
<h4> <h4>
<?php <?php
echo esc_html($productData['name']); echo esc_html($productData['name']);
if (!empty($durationLabel)) {
echo ' <span class="wclp-duration-badge">(' . esc_html($durationLabel) . ')</span>';
}
if ($productData['quantity'] > 1) { if ($productData['quantity'] > 1) {
printf(' (×%d)', $productData['quantity']); printf(' ×%d', $productData['quantity']);
} }
?> ?>
</h4> </h4>
<?php for ($i = 0; $i < $productData['quantity']; $i++): ?> <?php for ($i = 0; $i < $productData['quantity']; $i++): ?>
<?php <?php
$fieldName = sprintf('licensed_domains[%d][%d]', $productId, $i); $fieldName = sprintf('licensed_domains[%s][%d]', $fieldKey, $i);
$fieldId = sprintf('licensed_domain_%d_%d', $productId, $i); $fieldId = sprintf('licensed_domain_%s_%d', str_replace('_', '-', $fieldKey), $i);
$savedValue = $this->getSavedDomainValue($productId, $i); $savedValue = $this->getSavedDomainValue($productId, $i, $variationId);
?> ?>
<p class="form-row form-row-wide wclp-domain-row"> <p class="form-row form-row-wide wclp-domain-row">
<label for="<?php echo esc_attr($fieldId); ?>"> <label for="<?php echo esc_attr($fieldId); ?>">
@@ -186,6 +235,9 @@ final class CheckoutController
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>" placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>"
value="<?php echo esc_attr($savedValue); ?>" value="<?php echo esc_attr($savedValue); ?>"
/> />
<?php if ($variationId > 0): ?>
<input type="hidden" name="licensed_variation_ids[<?php echo esc_attr($fieldKey); ?>]" value="<?php echo esc_attr($variationId); ?>" />
<?php endif; ?>
</p> </p>
<?php endfor; ?> <?php endfor; ?>
</div> </div>
@@ -197,6 +249,7 @@ final class CheckoutController
.wclp-domain-description { margin-bottom: 15px; color: #666; } .wclp-domain-description { margin-bottom: 15px; color: #666; }
.wclp-product-domains { margin-bottom: 20px; padding: 15px; background: #f8f8f8; border-radius: 4px; } .wclp-product-domains { margin-bottom: 20px; padding: 15px; background: #f8f8f8; border-radius: 4px; }
.wclp-product-domains h4 { margin: 0 0 10px 0; font-size: 1em; } .wclp-product-domains h4 { margin: 0 0 10px 0; font-size: 1em; }
.wclp-duration-badge { color: #0073aa; font-weight: normal; }
.wclp-domain-row { margin-bottom: 10px; } .wclp-domain-row { margin-bottom: 10px; }
.wclp-domain-row:last-child { margin-bottom: 0; } .wclp-domain-row:last-child { margin-bottom: 0; }
.wclp-domain-row label { display: block; margin-bottom: 5px; } .wclp-domain-row label { display: block; margin-bottom: 5px; }
@@ -207,9 +260,17 @@ final class CheckoutController
/** /**
* Get saved domain value from session/POST * Get saved domain value from session/POST
*/ */
private function getSavedDomainValue(int $productId, int $index): string private function getSavedDomainValue(int $productId, int $index, int $variationId = 0): string
{ {
// Build the field key (with or without variation)
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
// Check POST data first (validation failure case) // Check POST data first (validation failure case)
if (isset($_POST['licensed_domains'][$fieldKey][$index])) {
return sanitize_text_field($_POST['licensed_domains'][$fieldKey][$index]);
}
// Also try numeric key for backward compatibility
if (isset($_POST['licensed_domains'][$productId][$index])) { if (isset($_POST['licensed_domains'][$productId][$index])) {
return sanitize_text_field($_POST['licensed_domains'][$productId][$index]); return sanitize_text_field($_POST['licensed_domains'][$productId][$index]);
} }
@@ -218,7 +279,11 @@ final class CheckoutController
if (WC()->session) { if (WC()->session) {
$sessionDomains = WC()->session->get('licensed_product_domains', []); $sessionDomains = WC()->session->get('licensed_product_domains', []);
foreach ($sessionDomains as $item) { foreach ($sessionDomains as $item) {
if (isset($item['product_id']) && (int) $item['product_id'] === $productId) { $itemProductId = (int) ($item['product_id'] ?? 0);
$itemVariationId = (int) ($item['variation_id'] ?? 0);
// Match by product and variation
if ($itemProductId === $productId && $itemVariationId === $variationId) {
if (isset($item['domains'][$index])) { if (isset($item['domains'][$index])) {
return $item['domains'][$index]; return $item['domains'][$index];
} }
@@ -272,8 +337,12 @@ final class CheckoutController
{ {
$licensedDomains = $_POST['licensed_domains'] ?? []; $licensedDomains = $_POST['licensed_domains'] ?? [];
foreach ($licensedProducts as $productId => $productData) { foreach ($licensedProducts as $key => $productData) {
$productDomains = $licensedDomains[$productId] ?? []; $productId = $productData['product_id'];
$variationId = $productData['variation_id'] ?? 0;
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
$productDomains = $licensedDomains[$fieldKey] ?? $licensedDomains[$productId] ?? [];
$normalizedDomains = []; $normalizedDomains = [];
for ($i = 0; $i < $productData['quantity']; $i++) { for ($i = 0; $i < $productData['quantity']; $i++) {
@@ -308,7 +377,7 @@ final class CheckoutController
continue; continue;
} }
// Check for duplicate domains within same product // Check for duplicate domains within same product/variation
if (in_array($normalizedDomain, $normalizedDomains, true)) { if (in_array($normalizedDomain, $normalizedDomains, true)) {
wc_add_notice( wc_add_notice(
sprintf( sprintf(
@@ -369,10 +438,15 @@ final class CheckoutController
private function saveMultiDomainFields(\WC_Order $order, array $licensedProducts): void private function saveMultiDomainFields(\WC_Order $order, array $licensedProducts): void
{ {
$licensedDomains = $_POST['licensed_domains'] ?? []; $licensedDomains = $_POST['licensed_domains'] ?? [];
$licensedVariationIds = $_POST['licensed_variation_ids'] ?? [];
$domainData = []; $domainData = [];
foreach ($licensedProducts as $productId => $productData) { foreach ($licensedProducts as $key => $productData) {
$productDomains = $licensedDomains[$productId] ?? []; $productId = $productData['product_id'];
$variationId = $productData['variation_id'] ?? 0;
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
$productDomains = $licensedDomains[$fieldKey] ?? $licensedDomains[$productId] ?? [];
$normalizedDomains = []; $normalizedDomains = [];
for ($i = 0; $i < $productData['quantity']; $i++) { for ($i = 0; $i < $productData['quantity']; $i++) {
@@ -383,10 +457,17 @@ final class CheckoutController
} }
if (!empty($normalizedDomains)) { if (!empty($normalizedDomains)) {
$domainData[] = [ $entry = [
'product_id' => $productId, 'product_id' => $productId,
'domains' => $normalizedDomains, 'domains' => $normalizedDomains,
]; ];
// Include variation_id if present
if ($variationId > 0) {
$entry['variation_id'] = $variationId;
}
$domainData[] = $entry;
} }
} }
@@ -432,8 +513,22 @@ final class CheckoutController
<strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong> <strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong>
<?php foreach ($domainData as $item): ?> <?php foreach ($domainData as $item): ?>
<?php <?php
$product = wc_get_product($item['product_id']); $productId = $item['product_id'];
$variationId = $item['variation_id'] ?? 0;
// Get product name
if ($variationId > 0) {
$variation = wc_get_product($variationId);
$productName = $variation ? $variation->get_name() : __('Unknown Variation', 'wc-licensed-product');
// Add duration label if available
if ($variation instanceof LicensedProductVariation) {
$productName .= ' (' . $variation->get_license_duration_label() . ')';
}
} else {
$product = wc_get_product($productId);
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'); $productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
}
?> ?>
<p style="margin: 5px 0 5px 15px;"> <p style="margin: 5px 0 5px 15px;">
<em><?php echo esc_html($productName); ?>:</em><br> <em><?php echo esc_html($productName); ?>:</em><br>
@@ -482,8 +577,20 @@ final class CheckoutController
if ($plainText) { if ($plainText) {
echo "\n" . esc_html__('License Domains:', 'wc-licensed-product') . "\n"; echo "\n" . esc_html__('License Domains:', 'wc-licensed-product') . "\n";
foreach ($domainData as $item) { foreach ($domainData as $item) {
$product = wc_get_product($item['product_id']); $productId = $item['product_id'];
$variationId = $item['variation_id'] ?? 0;
if ($variationId > 0) {
$variation = wc_get_product($variationId);
$productName = $variation ? $variation->get_name() : __('Unknown Variation', 'wc-licensed-product');
if ($variation instanceof LicensedProductVariation) {
$productName .= ' (' . $variation->get_license_duration_label() . ')';
}
} else {
$product = wc_get_product($productId);
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'); $productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
}
echo ' ' . esc_html($productName) . ': ' . esc_html(implode(', ', $item['domains'])) . "\n"; echo ' ' . esc_html($productName) . ': ' . esc_html(implode(', ', $item['domains'])) . "\n";
} }
} else { } else {
@@ -492,8 +599,19 @@ final class CheckoutController
<strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong> <strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong>
<?php foreach ($domainData as $item): ?> <?php foreach ($domainData as $item): ?>
<?php <?php
$product = wc_get_product($item['product_id']); $productId = $item['product_id'];
$variationId = $item['variation_id'] ?? 0;
if ($variationId > 0) {
$variation = wc_get_product($variationId);
$productName = $variation ? $variation->get_name() : __('Unknown Variation', 'wc-licensed-product');
if ($variation instanceof LicensedProductVariation) {
$productName .= ' (' . $variation->get_license_duration_label() . ')';
}
} else {
$product = wc_get_product($productId);
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'); $productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
}
?> ?>
<p style="margin: 5px 0 5px 15px;"> <p style="margin: 5px 0 5px 15px;">
<em><?php echo esc_html($productName); ?>:</em><br> <em><?php echo esc_html($productName); ?>:</em><br>

View File

@@ -100,6 +100,9 @@ final class StoreApiExtension
'product_id' => [ 'product_id' => [
'type' => 'integer', 'type' => 'integer',
], ],
'variation_id' => [
'type' => 'integer',
],
'domains' => [ 'domains' => [
'type' => 'array', 'type' => 'array',
'items' => [ 'items' => [
@@ -162,6 +165,7 @@ final class StoreApiExtension
} }
$productId = (int) $item['product_id']; $productId = (int) $item['product_id'];
$variationId = isset($item['variation_id']) ? (int) $item['variation_id'] : 0;
$domains = []; $domains = [];
foreach ($item['domains'] as $domain) { foreach ($item['domains'] as $domain) {
@@ -172,10 +176,17 @@ final class StoreApiExtension
} }
if (!empty($domains)) { if (!empty($domains)) {
$normalized[] = [ $entry = [
'product_id' => $productId, 'product_id' => $productId,
'domains' => $domains, 'domains' => $domains,
]; ];
// Include variation_id if present
if ($variationId > 0) {
$entry['variation_id'] = $variationId;
}
$normalized[] = $entry;
} }
} }
@@ -267,10 +278,23 @@ final class StoreApiExtension
// Check for licensed_domains in classic format (from DOM injection) // Check for licensed_domains in classic format (from DOM injection)
if (empty($domainData) && isset($requestData['licensed_domains']) && is_array($requestData['licensed_domains'])) { if (empty($domainData) && isset($requestData['licensed_domains']) && is_array($requestData['licensed_domains'])) {
$domainData = []; $domainData = [];
foreach ($requestData['licensed_domains'] as $productId => $domains) { $variationIds = $requestData['licensed_variation_ids'] ?? [];
foreach ($requestData['licensed_domains'] as $key => $domains) {
if (!is_array($domains)) { if (!is_array($domains)) {
continue; continue;
} }
// Parse key - could be "productId" or "productId_variationId"
$parts = explode('_', (string) $key);
$productId = (int) $parts[0];
$variationId = isset($parts[1]) ? (int) $parts[1] : 0;
// Also check for hidden variation ID field
if ($variationId === 0 && isset($variationIds[$key])) {
$variationId = (int) $variationIds[$key];
}
$normalizedDomains = []; $normalizedDomains = [];
foreach ($domains as $domain) { foreach ($domains as $domain) {
$sanitized = sanitize_text_field($domain); $sanitized = sanitize_text_field($domain);
@@ -279,10 +303,16 @@ final class StoreApiExtension
} }
} }
if (!empty($normalizedDomains)) { if (!empty($normalizedDomains)) {
$domainData[] = [ $entry = [
'product_id' => (int) $productId, 'product_id' => $productId,
'domains' => $normalizedDomains, 'domains' => $normalizedDomains,
]; ];
if ($variationId > 0) {
$entry['variation_id'] = $variationId;
}
$domainData[] = $entry;
} }
} }
} }

View File

@@ -11,12 +11,43 @@ namespace Jeremias\WcLicensedProduct\License;
use Jeremias\WcLicensedProduct\Installer; use Jeremias\WcLicensedProduct\Installer;
use Jeremias\WcLicensedProduct\Product\LicensedProduct; use Jeremias\WcLicensedProduct\Product\LicensedProduct;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
use Jeremias\WcLicensedProduct\Product\LicensedVariableProduct;
/** /**
* Manages license operations (CRUD, validation, generation) * Manages license operations (CRUD, validation, generation)
*/ */
class LicenseManager class LicenseManager
{ {
/**
* Check if a product is any type of licensed product
*
* @param \WC_Product $product Product to check
* @return bool True if product is licensed (simple or variable or variation)
*/
public function isLicensedProduct(\WC_Product $product): bool
{
// Simple licensed product
if ($product->is_type('licensed')) {
return true;
}
// Variable licensed product
if ($product->is_type('licensed-variable')) {
return true;
}
// Variation of a licensed-variable product
if ($product->is_type('variation') && $product->get_parent_id()) {
$parent = wc_get_product($product->get_parent_id());
if ($parent && $parent->is_type('licensed-variable')) {
return true;
}
}
return false;
}
/** /**
* Generate a unique license key * Generate a unique license key
*/ */
@@ -40,32 +71,63 @@ class LicenseManager
/** /**
* Generate a license for a completed order * Generate a license for a completed order
*
* @param int $orderId Order ID
* @param int $productId Product ID (parent product for variations)
* @param int $customerId Customer ID
* @param string $domain Domain to bind the license to
* @param int|null $variationId Optional variation ID for variable licensed products
* @return License|null Generated license or null on failure
*/ */
public function generateLicense( public function generateLicense(
int $orderId, int $orderId,
int $productId, int $productId,
int $customerId, int $customerId,
string $domain string $domain,
?int $variationId = null
): ?License { ): ?License {
global $wpdb; global $wpdb;
// Normalize domain first for duplicate detection // Normalize domain first for duplicate detection
$normalizedDomain = $this->normalizeDomain($domain); $normalizedDomain = $this->normalizeDomain($domain);
// Check if license already exists for this order, product, and domain // Check if license already exists for this order, product, domain, and variation
$existing = $this->getLicenseByOrderProductAndDomain($orderId, $productId, $normalizedDomain); $existing = $this->getLicenseByOrderProductDomainAndVariation($orderId, $productId, $normalizedDomain, $variationId);
if ($existing) { if ($existing) {
return $existing; return $existing;
} }
$product = wc_get_product($productId); // Load the product that has the license settings
if (!$product || !$product->is_type('licensed')) { // For variations, load the variation; otherwise load the parent product
if ($variationId) {
$settingsProduct = wc_get_product($variationId);
$parentProduct = wc_get_product($productId);
// Verify parent is licensed-variable
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
return null;
}
// Ensure we have the proper variation class
if ($settingsProduct && !$settingsProduct instanceof LicensedProductVariation) {
$settingsProduct = new LicensedProductVariation($variationId);
}
} else {
$settingsProduct = wc_get_product($productId);
// Check if this is a licensed product (simple)
if (!$settingsProduct || !$settingsProduct->is_type('licensed')) {
return null; return null;
} }
// Ensure we have the LicensedProduct instance for type hints // Ensure we have the LicensedProduct instance for type hints
if (!$product instanceof LicensedProduct) { if (!$settingsProduct instanceof LicensedProduct) {
$product = new LicensedProduct($productId); $settingsProduct = new LicensedProduct($productId);
}
}
if (!$settingsProduct) {
return null;
} }
// Generate unique license key // Generate unique license key
@@ -74,16 +136,16 @@ class LicenseManager
$licenseKey = $this->generateLicenseKey(); $licenseKey = $this->generateLicenseKey();
} }
// Calculate expiration date // Calculate expiration date from the settings product (variation or parent)
$expiresAt = null; $expiresAt = null;
$validityDays = $product->get_validity_days(); $validityDays = $settingsProduct->get_validity_days();
if ($validityDays !== null && $validityDays > 0) { if ($validityDays !== null && $validityDays > 0) {
$expiresAt = (new \DateTimeImmutable())->modify("+{$validityDays} days")->format('Y-m-d H:i:s'); $expiresAt = (new \DateTimeImmutable())->modify("+{$validityDays} days")->format('Y-m-d H:i:s');
} }
// Determine version ID if bound to version // Determine version ID if bound to version (always use parent product ID for versions)
$versionId = null; $versionId = null;
if ($product->is_bound_to_version()) { if ($settingsProduct->is_bound_to_version()) {
$versionId = $this->getCurrentVersionId($productId); $versionId = $this->getCurrentVersionId($productId);
} }
@@ -99,7 +161,7 @@ class LicenseManager
'version_id' => $versionId, 'version_id' => $versionId,
'status' => License::STATUS_ACTIVE, 'status' => License::STATUS_ACTIVE,
'activations_count' => 1, 'activations_count' => 1,
'max_activations' => $product->get_max_activations(), 'max_activations' => $settingsProduct->get_max_activations(),
'expires_at' => $expiresAt, 'expires_at' => $expiresAt,
], ],
['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s'] ['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s']
@@ -112,6 +174,16 @@ class LicenseManager
return $this->getLicenseById((int) $wpdb->insert_id); return $this->getLicenseById((int) $wpdb->insert_id);
} }
/**
* Get license by order, product, domain, and optional variation
*/
public function getLicenseByOrderProductDomainAndVariation(int $orderId, int $productId, string $domain, ?int $variationId = null): ?License
{
// For now, just use the existing method since we don't store variation_id in licenses table yet
// In the future, we could add a variation_id column to the licenses table
return $this->getLicenseByOrderProductAndDomain($orderId, $productId, $domain);
}
/** /**
* Get license by ID * Get license by ID
*/ */

View File

@@ -227,23 +227,35 @@ final class Plugin
$orderId = $order->get_id(); $orderId = $order->get_id();
$customerId = $order->get_customer_id(); $customerId = $order->get_customer_id();
// Index domains by product ID for quick lookup // Index domains by product ID (and variation ID for variable products)
$domainsByProduct = []; $domainsByProduct = [];
foreach ($domainData as $item) { foreach ($domainData as $item) {
if (isset($item['product_id']) && isset($item['domains']) && is_array($item['domains'])) { if (isset($item['product_id']) && isset($item['domains']) && is_array($item['domains'])) {
$domainsByProduct[(int) $item['product_id']] = $item['domains']; $productId = (int) $item['product_id'];
$variationId = isset($item['variation_id']) ? (int) $item['variation_id'] : 0;
$key = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
$domainsByProduct[$key] = [
'domains' => $item['domains'],
'variation_id' => $variationId,
];
} }
} }
// 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 || !$product->is_type('licensed')) { if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
continue; continue;
} }
$productId = $product->get_id(); // Get the parent product ID (for variations, this is the main product)
$domains = $domainsByProduct[$productId] ?? []; $productId = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();
$variationId = $item->get_variation_id();
// Look up domains - first try with variation, then without
$key = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
$domainInfo = $domainsByProduct[$key] ?? $domainsByProduct[(string) $productId] ?? null;
$domains = $domainInfo['domains'] ?? [];
// Generate a license for each domain // Generate a license for each domain
foreach ($domains as $domain) { foreach ($domains as $domain) {
@@ -252,7 +264,8 @@ final class Plugin
$orderId, $orderId,
$productId, $productId,
$customerId, $customerId,
$domain $domain,
$variationId > 0 ? $variationId : null
); );
} }
} }
@@ -271,12 +284,17 @@ final class Plugin
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)) {
// Get the parent product ID (for variations, this is the main product)
$productId = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();
$variationId = $item->get_variation_id();
$this->licenseManager->generateLicense( $this->licenseManager->generateLicense(
$order->get_id(), $order->get_id(),
$product->get_id(), $productId,
$order->get_customer_id(), $order->get_customer_id(),
$domain $domain,
$variationId > 0 ? $variationId : null
); );
} }
} }

View File

@@ -12,7 +12,8 @@ namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\SettingsController;
/** /**
* Registers and handles the Licensed product type for WooCommerce * Registers and handles the Licensed product types for WooCommerce
* Supports both simple licensed products and variable licensed products
*/ */
final class LicensedProductType final class LicensedProductType
{ {
@@ -29,7 +30,7 @@ final class LicensedProductType
*/ */
private function registerHooks(): void private function registerHooks(): void
{ {
// Register product type // 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, 2); add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 2);
@@ -39,9 +40,11 @@ final class LicensedProductType
// Save product meta // Save product meta
add_action('woocommerce_process_product_meta_licensed', [$this, 'saveProductMeta']); add_action('woocommerce_process_product_meta_licensed', [$this, 'saveProductMeta']);
add_action('woocommerce_process_product_meta_licensed-variable', [$this, 'saveProductMeta']);
// Show price and add to cart for licensed products // Show price and add to cart for licensed products
add_action('woocommerce_licensed_add_to_cart', [$this, 'addToCartTemplate']); add_action('woocommerce_licensed_add_to_cart', [$this, 'addToCartTemplate']);
add_action('woocommerce_licensed-variable_add_to_cart', [$this, 'variableAddToCartTemplate']);
// Make product virtual by default // Make product virtual by default
add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2); add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2);
@@ -51,25 +54,48 @@ final class LicensedProductType
// Enqueue frontend CSS for licensed products on single product pages // Enqueue frontend CSS for licensed products on single product pages
add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendStyles']); add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendStyles']);
// Variable product support - variation settings
add_action('woocommerce_variation_options', [$this, 'addVariationOptions'], 10, 3);
add_action('woocommerce_product_after_variable_attributes', [$this, 'addVariationFields'], 10, 3);
add_action('woocommerce_save_product_variation', [$this, 'saveVariationFields'], 10, 2);
// Admin scripts for licensed-variable type
add_action('admin_footer', [$this, 'addVariableProductScripts']);
} }
/** /**
* Add product type to selector * Add product types to selector
*/ */
public function addProductType(array $types): array public function addProductType(array $types): array
{ {
$types['licensed'] = __('Licensed Product', 'wc-licensed-product'); $types['licensed'] = __('Licensed Product', 'wc-licensed-product');
$types['licensed-variable'] = __('Licensed Variable Product', 'wc-licensed-product');
return $types; return $types;
} }
/** /**
* Get product class for licensed type * Get product class for licensed types
*/ */
public function getProductClass(string $className, string $productType): string public function getProductClass(string $className, string $productType): string
{ {
if ($productType === 'licensed') { if ($productType === 'licensed') {
return LicensedProduct::class; return LicensedProduct::class;
} }
if ($productType === 'licensed-variable') {
return LicensedVariableProduct::class;
}
// Handle variations of licensed-variable products
if ($productType === 'variation') {
// Check if parent is licensed-variable
global $post;
if ($post && $post->post_parent) {
$parentType = \WC_Product_Factory::get_product_type($post->post_parent);
if ($parentType === 'licensed-variable') {
return LicensedProductVariation::class;
}
}
}
return $className; return $className;
} }
@@ -81,7 +107,7 @@ final class LicensedProductType
$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'], 'class' => ['show_if_licensed', 'show_if_licensed-variable'],
'priority' => 21, 'priority' => 21,
]; ];
return $tabs; return $tabs;
@@ -236,9 +262,16 @@ final class LicensedProductType
*/ */
public function isVirtual(bool $isVirtual, \WC_Product $product): bool public function isVirtual(bool $isVirtual, \WC_Product $product): bool
{ {
if ($product->is_type('licensed')) { if ($product->is_type('licensed') || $product->is_type('licensed-variable')) {
return true; return true;
} }
// Also handle variations of licensed-variable products
if ($product->is_type('variation') && $product->get_parent_id()) {
$parent = wc_get_product($product->get_parent_id());
if ($parent && $parent->is_type('licensed-variable')) {
return true;
}
}
return $isVirtual; return $isVirtual;
} }
@@ -253,7 +286,7 @@ final class LicensedProductType
global $product; global $product;
if (!$product || !$product->is_type('licensed')) { if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
return; return;
} }
@@ -272,11 +305,11 @@ final class LicensedProductType
{ {
global $product; global $product;
if (!$product || !$product->is_type('licensed')) { if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
return; return;
} }
/** @var LicensedProduct $product */ /** @var LicensedProduct|LicensedVariableProduct $product */
$version = $product->get_current_version(); $version = $product->get_current_version();
if (empty($version)) { if (empty($version)) {
@@ -289,4 +322,200 @@ final class LicensedProductType
esc_html($version) esc_html($version)
); );
} }
/**
* Add to cart template for variable licensed products
*/
public function variableAddToCartTemplate(): void
{
wc_get_template('single-product/add-to-cart/variable.php');
}
/**
* Add variation options (checkboxes next to variation header)
*/
public function addVariationOptions(int $loop, array $variationData, \WP_Post $variation): void
{
// Check if parent is licensed-variable
$parentId = $variation->post_parent;
$parentProduct = wc_get_product($parentId);
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
return;
}
$isVirtual = get_post_meta($variation->ID, '_virtual', true);
?>
<label class="tips" data-tip="<?php esc_attr_e('Licensed products are always virtual', 'wc-licensed-product'); ?>">
<input type="checkbox" class="checkbox" disabled checked />
<?php esc_html_e('Virtual', 'wc-licensed-product'); ?>
</label>
<?php
}
/**
* Add variation fields for license settings
*/
public function addVariationFields(int $loop, array $variationData, \WP_Post $variation): void
{
// Check if parent is licensed-variable
$parentId = $variation->post_parent;
$parentProduct = wc_get_product($parentId);
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
return;
}
// Get variation values
$validityDays = get_post_meta($variation->ID, '_licensed_validity_days', true);
$maxActivations = get_post_meta($variation->ID, '_licensed_max_activations', true);
// Get parent defaults for placeholder
$parentValidityDays = $parentProduct->get_validity_days();
$parentMaxActivations = $parentProduct->get_max_activations();
$parentValidityDisplay = $parentValidityDays !== null
? sprintf(__('%d days', 'wc-licensed-product'), $parentValidityDays)
: __('Lifetime', 'wc-licensed-product');
?>
<div class="wclp-variation-license-settings">
<p class="form-row form-row-first">
<label><?php esc_html_e('License Duration (Days)', 'wc-licensed-product'); ?></label>
<input type="number"
name="wclp_validity_days[<?php echo esc_attr($loop); ?>]"
class="short"
min="0"
step="1"
placeholder="<?php echo esc_attr($parentValidityDisplay); ?>"
value="<?php echo esc_attr($validityDays); ?>"
/>
<span class="description"><?php esc_html_e('Leave empty for parent default. 0 = Lifetime.', 'wc-licensed-product'); ?></span>
</p>
<p class="form-row form-row-last">
<label><?php esc_html_e('Max Activations', 'wc-licensed-product'); ?></label>
<input type="number"
name="wclp_max_activations[<?php echo esc_attr($loop); ?>]"
class="short"
min="1"
step="1"
placeholder="<?php echo esc_attr($parentMaxActivations); ?>"
value="<?php echo esc_attr($maxActivations); ?>"
/>
<span class="description"><?php esc_html_e('Leave empty for parent default.', 'wc-licensed-product'); ?></span>
</p>
</div>
<style>
.wclp-variation-license-settings {
background: #f8f8f8;
border: 1px solid #e5e5e5;
padding: 10px;
margin: 10px 0;
border-radius: 4px;
}
.wclp-variation-license-settings .description {
display: block;
font-style: italic;
color: #666;
margin-top: 4px;
}
</style>
<?php
}
/**
* Save variation fields
*/
public function saveVariationFields(int $variationId, int $loop): void
{
// Check if parent is licensed-variable
$variation = wc_get_product($variationId);
if (!$variation) {
return;
}
$parentProduct = wc_get_product($variation->get_parent_id());
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
return;
}
// Save validity days
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified by WooCommerce
if (isset($_POST['wclp_validity_days'][$loop])) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$validityDays = sanitize_text_field($_POST['wclp_validity_days'][$loop]);
if ($validityDays !== '') {
update_post_meta($variationId, '_licensed_validity_days', absint($validityDays));
} else {
delete_post_meta($variationId, '_licensed_validity_days');
}
}
// Save max activations
// phpcs:ignore WordPress.Security.NonceVerification.Missing
if (isset($_POST['wclp_max_activations'][$loop])) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$maxActivations = sanitize_text_field($_POST['wclp_max_activations'][$loop]);
if ($maxActivations !== '') {
update_post_meta($variationId, '_licensed_max_activations', absint($maxActivations));
} else {
delete_post_meta($variationId, '_licensed_max_activations');
}
}
// Set variation as virtual (licensed products are always virtual)
update_post_meta($variationId, '_virtual', 'yes');
}
/**
* Add JavaScript for licensed-variable product type in admin
*/
public function addVariableProductScripts(): void
{
global $post, $pagenow;
if ($pagenow !== 'post.php' && $pagenow !== 'post-new.php') {
return;
}
if (!$post || get_post_type($post) !== 'product') {
return;
}
?>
<script type="text/javascript">
jQuery(document).ready(function($) {
// Show/hide panels based on product type
function toggleLicensedVariableOptions() {
var productType = $('#product-type').val();
if (productType === 'licensed-variable') {
// Show variable product options
$('.show_if_variable').show();
$('.hide_if_variable').hide();
// Show licensed product options
$('.show_if_licensed-variable').show();
$('.show_if_licensed').show();
// Show general and variations tabs
$('.general_tab').show();
$('.variations_tab').show();
// Hide shipping tab (virtual products)
$('.shipping_tab').hide();
}
}
// Initial check
toggleLicensedVariableOptions();
// On product type change
$('#product-type').on('change', function() {
toggleLicensedVariableOptions();
});
});
</script>
<?php
}
} }

View File

@@ -0,0 +1,196 @@
<?php
/**
* Licensed Product Variation Class
*
* @package Jeremias\WcLicensedProduct\Product
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use WC_Product_Variation;
/**
* Licensed Product Variation type extending WooCommerce Product Variation
*
* Each variation can have its own license duration settings.
*/
class LicensedProductVariation extends WC_Product_Variation
{
/**
* Constructor
*/
public function __construct($product = 0)
{
parent::__construct($product);
}
/**
* Licensed products are always virtual
*/
public function is_virtual(): bool
{
return true;
}
/**
* Get max activations for this variation
* Falls back to parent product, then to default settings
*/
public function get_max_activations(): int
{
// Check variation-specific setting first
$value = $this->get_meta('_licensed_max_activations', true);
if ($value !== '' && $value !== null) {
return max(1, (int) $value);
}
// Fall back to parent product
$parent = wc_get_product($this->get_parent_id());
if ($parent && method_exists($parent, 'get_max_activations')) {
return $parent->get_max_activations();
}
return SettingsController::getDefaultMaxActivations();
}
/**
* Check if variation has custom max activations set
*/
public function has_custom_max_activations(): bool
{
$value = $this->get_meta('_licensed_max_activations', true);
return $value !== '' && $value !== null;
}
/**
* Get validity days for this variation
* This is the primary license setting that varies per variation
* Falls back to parent product, then to default settings
*/
public function get_validity_days(): ?int
{
// Check variation-specific setting first
$value = $this->get_meta('_licensed_validity_days', true);
if ($value !== '' && $value !== null) {
$days = (int) $value;
// 0 means lifetime
return $days > 0 ? $days : null;
}
// Fall back to parent product
$parent = wc_get_product($this->get_parent_id());
if ($parent && method_exists($parent, 'get_validity_days')) {
return $parent->get_validity_days();
}
return SettingsController::getDefaultValidityDays();
}
/**
* Check if variation has custom validity days set
*/
public function has_custom_validity_days(): bool
{
$value = $this->get_meta('_licensed_validity_days', true);
return $value !== '' && $value !== null;
}
/**
* Check if license should be bound to major version
* Falls back to parent product, then to default settings
*/
public function is_bound_to_version(): bool
{
// Check variation-specific setting first
$value = $this->get_meta('_licensed_bind_to_version', true);
if ($value !== '' && $value !== null) {
return $value === 'yes';
}
// Fall back to parent product
$parent = wc_get_product($this->get_parent_id());
if ($parent && method_exists($parent, 'is_bound_to_version')) {
return $parent->is_bound_to_version();
}
return SettingsController::getDefaultBindToVersion();
}
/**
* Check if variation has custom bind to version setting
*/
public function has_custom_bind_to_version(): bool
{
$value = $this->get_meta('_licensed_bind_to_version', true);
return $value !== '' && $value !== null;
}
/**
* Get the license duration label for display
*/
public function get_license_duration_label(): string
{
$days = $this->get_validity_days();
if ($days === null) {
return __('Lifetime', 'wc-licensed-product');
}
if ($days === 30) {
return __('Monthly', 'wc-licensed-product');
}
if ($days === 90) {
return __('Quarterly', 'wc-licensed-product');
}
if ($days === 365) {
return __('Yearly', 'wc-licensed-product');
}
return sprintf(
/* translators: %d: number of days */
_n('%d day', '%d days', $days, 'wc-licensed-product'),
$days
);
}
/**
* Get current software version from parent product
*/
public function get_current_version(): string
{
$parent = wc_get_product($this->get_parent_id());
if ($parent && method_exists($parent, 'get_current_version')) {
return $parent->get_current_version();
}
$versionManager = new VersionManager();
$latestVersion = $versionManager->getLatestVersion($this->get_parent_id());
return $latestVersion ? $latestVersion->getVersion() : '';
}
/**
* Get major version number from parent product
*/
public function get_major_version(): int
{
$parent = wc_get_product($this->get_parent_id());
if ($parent && method_exists($parent, 'get_major_version')) {
return $parent->get_major_version();
}
$versionManager = new VersionManager();
$latestVersion = $versionManager->getLatestVersion($this->get_parent_id());
if ($latestVersion) {
return $latestVersion->getMajorVersion();
}
return 1;
}
}

View File

@@ -0,0 +1,151 @@
<?php
/**
* Licensed Variable Product Class
*
* @package Jeremias\WcLicensedProduct\Product
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use WC_Product_Variable;
/**
* Licensed Variable Product type extending WooCommerce Variable Product
*
* This allows selling license subscriptions with different durations
* (e.g., monthly, yearly, lifetime) as product variations.
*/
class LicensedVariableProduct extends WC_Product_Variable
{
/**
* Product type
*/
protected $product_type = 'licensed-variable';
/**
* Constructor
*/
public function __construct($product = 0)
{
parent::__construct($product);
}
/**
* Get product type
*/
public function get_type(): string
{
return 'licensed-variable';
}
/**
* Licensed products are always virtual
*/
public function is_virtual(): bool
{
return true;
}
/**
* Licensed products are purchasable
*/
public function is_purchasable(): bool
{
return $this->exists() && $this->get_price() !== '';
}
/**
* Get max activations for this product (parent default)
* Falls back to default settings if not set on product
*/
public function get_max_activations(): int
{
$value = $this->get_meta('_licensed_max_activations', true);
if ($value !== '' && $value !== null) {
return max(1, (int) $value);
}
return SettingsController::getDefaultMaxActivations();
}
/**
* Check if product has custom max activations set
*/
public function has_custom_max_activations(): bool
{
$value = $this->get_meta('_licensed_max_activations', true);
return $value !== '' && $value !== null;
}
/**
* Get validity days (parent default - variations override this)
* Falls back to default settings if not set on product
*/
public function get_validity_days(): ?int
{
$value = $this->get_meta('_licensed_validity_days', true);
if ($value !== '' && $value !== null) {
return (int) $value > 0 ? (int) $value : null;
}
return SettingsController::getDefaultValidityDays();
}
/**
* Check if product has custom validity days set
*/
public function has_custom_validity_days(): bool
{
$value = $this->get_meta('_licensed_validity_days', true);
return $value !== '' && $value !== null;
}
/**
* Check if license should be bound to major version
* Falls back to default settings if not set on product
*/
public function is_bound_to_version(): bool
{
$value = $this->get_meta('_licensed_bind_to_version', true);
if ($value !== '' && $value !== null) {
return $value === 'yes';
}
return SettingsController::getDefaultBindToVersion();
}
/**
* Check if product has custom bind to version setting
*/
public function has_custom_bind_to_version(): bool
{
$value = $this->get_meta('_licensed_bind_to_version', true);
return $value !== '' && $value !== null;
}
/**
* Get current software version (derived from latest product version)
*/
public function get_current_version(): string
{
$versionManager = new VersionManager();
$latestVersion = $versionManager->getLatestVersion($this->get_id());
return $latestVersion ? $latestVersion->getVersion() : '';
}
/**
* Get major version number from version string
*/
public function get_major_version(): int
{
$versionManager = new VersionManager();
$latestVersion = $versionManager->getLatestVersion($this->get_id());
if ($latestVersion) {
return $latestVersion->getMajorVersion();
}
return 1;
}
}

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.2 * Version: 0.5.5
* 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.2'); define('WC_LICENSED_PRODUCT_VERSION', '0.5.5');
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__));