You've already forked wc-licensed-product
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f676556f2 | |||
| 5f51aafe3b | |||
| 279b0d5dd6 | |||
| 086755cb11 | |||
| 0b58de193e | |||
| ae49b262fa | |||
| 5d5bb7e595 | |||
| bee9854c18 | |||
| c31df1e8c4 | |||
| 8cac742f57 |
71
CHANGELOG.md
71
CHANGELOG.md
@@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.6] - 2026-01-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- License Settings tab now only shows for Licensed Product and Licensed Variable Product types
|
||||||
|
- Previously the tab was visible on all product types due to CSS `!important` override
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved JavaScript for License Settings tab visibility handling on product type change
|
||||||
|
- Updated README.md with complete feature documentation for v0.5.x features:
|
||||||
|
- Variable Licensed Products
|
||||||
|
- Multi-Domain Licensing
|
||||||
|
- Per-License Customer Secrets
|
||||||
|
- Download Statistics
|
||||||
|
- Configurable Rate Limiting
|
||||||
|
|
||||||
|
## [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
152
CLAUDE.md
@@ -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
|
||||||
|
|||||||
46
README.md
46
README.md
@@ -11,29 +11,34 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
|||||||
### Core Features
|
### Core Features
|
||||||
|
|
||||||
- **Licensed Product Type**: New WooCommerce product type for software sales
|
- **Licensed Product Type**: New WooCommerce product type for software sales
|
||||||
|
- **Variable Licensed Products**: Create product variations with different license durations (monthly, yearly, lifetime)
|
||||||
- **Automatic License Generation**: License keys generated on order completion (format: XXXX-XXXX-XXXX-XXXX)
|
- **Automatic License Generation**: License keys generated on order completion (format: XXXX-XXXX-XXXX-XXXX)
|
||||||
- **Domain Binding**: Licenses are bound to customer-specified domains
|
- **Domain Binding**: Licenses are bound to customer-specified domains
|
||||||
|
- **Multi-Domain Licensing**: Customers can purchase multiple licenses for different domains in a single order
|
||||||
- **REST API**: Public endpoints for license validation and management
|
- **REST API**: Public endpoints for license validation and management
|
||||||
- **Response Signing**: Optional HMAC-SHA256 cryptographic signatures for API responses
|
- **Response Signing**: Optional HMAC-SHA256 cryptographic signatures for API responses
|
||||||
|
- **Per-License Secrets**: Each customer receives a unique verification secret for their license
|
||||||
- **Version Binding**: Optional binding to major software versions
|
- **Version Binding**: Optional binding to major software versions
|
||||||
- **Expiration Support**: Set license validity periods or lifetime licenses
|
- **Expiration Support**: Set license validity periods or lifetime licenses
|
||||||
- **Rate Limiting**: API endpoints protected with rate limiting (30 requests/minute)
|
- **Rate Limiting**: API endpoints protected with configurable rate limiting (default: 30 requests/minute)
|
||||||
- **Trusted Proxy Support**: Configurable trusted proxies for accurate rate limiting behind CDNs
|
- **Trusted Proxy Support**: Configurable trusted proxies for accurate rate limiting behind CDNs
|
||||||
- **Checkout Blocks**: Full support for WooCommerce Checkout Blocks (default since WC 8.3+)
|
- **Checkout Blocks**: Full support for WooCommerce Checkout Blocks (default since WC 8.3+)
|
||||||
- **Self-Licensing**: The plugin can validate its own license (for commercial distribution)
|
- **Self-Licensing**: The plugin can validate its own license (for commercial distribution)
|
||||||
|
|
||||||
### Customer Features
|
### Customer Features
|
||||||
|
|
||||||
- **My Account Licenses**: Customers can view their licenses in My Account
|
- **My Account Licenses**: Customers can view their licenses in My Account (grouped by product)
|
||||||
- **License Transfers**: Customers can transfer licenses to new domains
|
- **License Transfers**: Customers can transfer licenses to new domains
|
||||||
- **Secure Downloads**: Download purchased software versions with license verification
|
- **Secure Downloads**: Download purchased software versions with license verification
|
||||||
|
- **Version History**: Access to older versions with collapsible download section
|
||||||
- **Copy to Clipboard**: Easy license key copying
|
- **Copy to Clipboard**: Easy license key copying
|
||||||
|
- **API Verification Secret**: Per-license secret displayed for secure API integration
|
||||||
|
|
||||||
### Admin Features
|
### Admin Features
|
||||||
|
|
||||||
- **License Management**: Full CRUD interface for license management
|
- **License Management**: Full CRUD interface for license management
|
||||||
- **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses)
|
- **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses)
|
||||||
- **Dashboard Widget**: License statistics on WordPress admin dashboard
|
- **Dashboard Widgets**: License statistics and download statistics on WordPress admin dashboard
|
||||||
- **Search & Filtering**: Search by license key, domain, status, or product
|
- **Search & Filtering**: Search by license key, domain, status, or product
|
||||||
- **Live Search**: AJAX-powered instant search results
|
- **Live Search**: AJAX-powered instant search results
|
||||||
- **Inline Editing**: Edit license status, expiry, and domain directly in the list
|
- **Inline Editing**: Edit license status, expiry, and domain directly in the list
|
||||||
@@ -41,10 +46,12 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
|||||||
- **License Transfer**: Transfer licenses to new domains
|
- **License Transfer**: Transfer licenses to new domains
|
||||||
- **CSV Export/Import**: Export and import licenses via CSV
|
- **CSV Export/Import**: Export and import licenses via CSV
|
||||||
- **Order Integration**: View and manage licenses directly from order pages
|
- **Order Integration**: View and manage licenses directly from order pages
|
||||||
|
- **Generate Licenses**: Manually generate licenses for admin-created orders
|
||||||
- **Expiration Warnings**: Automatic email notifications before license expiration
|
- **Expiration Warnings**: Automatic email notifications before license expiration
|
||||||
- **Auto-Expire**: Daily cron job automatically expires licenses past their expiration date
|
- **Auto-Expire**: Daily cron job automatically expires licenses past their expiration date
|
||||||
- **License Testing**: Test licenses against the API directly from admin interface
|
- **License Testing**: Test licenses against the API directly from admin interface
|
||||||
- **Version Management**: Manage multiple versions per product with file attachments
|
- **Version Management**: Manage multiple versions per product with file attachments
|
||||||
|
- **Download Tracking**: Track download counts per version with statistics widget
|
||||||
- **SHA256 Checksums**: File integrity verification with SHA256 hash display
|
- **SHA256 Checksums**: File integrity verification with SHA256 hash display
|
||||||
- **Global Settings**: Default license settings via WooCommerce settings tab
|
- **Global Settings**: Default license settings via WooCommerce settings tab
|
||||||
- **WooCommerce HPOS**: Compatible with High-Performance Order Storage
|
- **WooCommerce HPOS**: Compatible with High-Performance Order Storage
|
||||||
@@ -66,13 +73,27 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
|||||||
### Creating a Licensed Product
|
### Creating a Licensed Product
|
||||||
|
|
||||||
1. Go to Products > Add New
|
1. Go to Products > Add New
|
||||||
2. Select "Licensed Product" from the product type dropdown
|
2. Select "Licensed Product" from the product type dropdown (or "Licensed Variable Product" for different license durations)
|
||||||
3. Configure the product price in the General tab
|
3. Configure the product price in the General tab
|
||||||
4. Set license options in the "License Settings" tab:
|
4. Set license options in the "License Settings" tab:
|
||||||
- **Max Activations**: Number of domains allowed per license
|
- **Max Activations**: Number of domains allowed per license
|
||||||
- **License Validity**: Days until expiration (empty = lifetime)
|
- **License Validity**: Days until expiration (empty = lifetime)
|
||||||
- **Bind to Major Version**: Lock license to current major version
|
- **Bind to Major Version**: Lock license to current major version
|
||||||
|
|
||||||
|
### Creating Variable Licensed Products
|
||||||
|
|
||||||
|
For selling licenses with different durations (monthly, yearly, lifetime):
|
||||||
|
|
||||||
|
1. Go to Products > Add New
|
||||||
|
2. Select "Licensed Variable Product" from the product type dropdown
|
||||||
|
3. Create variations as you would for any variable product (e.g., by "License Duration")
|
||||||
|
4. For each variation, set:
|
||||||
|
- **Variation Price**: Different prices for different durations
|
||||||
|
- **License Duration (Days)**: Days until expiration (0 = lifetime)
|
||||||
|
- **Max Activations**: Override parent product setting if needed
|
||||||
|
|
||||||
|
Duration labels (Monthly, Yearly, Lifetime) are automatically displayed at checkout.
|
||||||
|
|
||||||
### Managing Product Versions
|
### Managing Product Versions
|
||||||
|
|
||||||
1. Edit a Licensed Product
|
1. Edit a Licensed Product
|
||||||
@@ -84,7 +105,8 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
|||||||
|
|
||||||
1. Go to WooCommerce > Settings > Licensed Products
|
1. Go to WooCommerce > Settings > Licensed Products
|
||||||
2. Set default values for Max Activations, License Validity, and Version Binding
|
2. Set default values for Max Activations, License Validity, and Version Binding
|
||||||
3. Per-product settings override these defaults
|
3. Enable Multi-Domain Licensing to allow multiple licenses per cart item
|
||||||
|
4. Per-product settings override these defaults
|
||||||
|
|
||||||
### Customer Checkout
|
### Customer Checkout
|
||||||
|
|
||||||
@@ -144,6 +166,18 @@ define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE,10.0.0.1');
|
|||||||
|
|
||||||
**Note**: Only configure trusted proxies if you actually use them. Without this configuration, rate limiting is more secure against IP spoofing attacks.
|
**Note**: Only configure trusted proxies if you actually use them. Without this configuration, rate limiting is more secure against IP spoofing attacks.
|
||||||
|
|
||||||
|
### Configurable Rate Limiting
|
||||||
|
|
||||||
|
The default rate limit is 30 requests per 60 seconds. You can customize this:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Requests allowed per window (default: 30)
|
||||||
|
define('WC_LICENSE_RATE_LIMIT', 60);
|
||||||
|
|
||||||
|
// Window duration in seconds (default: 60)
|
||||||
|
define('WC_LICENSE_RATE_WINDOW', 120);
|
||||||
|
```
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
|
|
||||||
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
|
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
|
||||||
@@ -173,6 +207,8 @@ openssl rand -hex 32
|
|||||||
|
|
||||||
The signature prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` Composer package with the `SecureLicenseClient` class to automatically verify signatures.
|
The signature prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` Composer package with the `SecureLicenseClient` class to automatically verify signatures.
|
||||||
|
|
||||||
|
**Per-License Customer Secrets**: Each customer receives a unique verification secret derived from their license key. This secret is displayed in their account page under "API Verification Secret" and can be used with the client library instead of sharing the master server secret.
|
||||||
|
|
||||||
### Client Libraries & Examples
|
### Client Libraries & Examples
|
||||||
|
|
||||||
**PHP (Recommended):** Install the official client library via Composer:
|
**PHP (Recommended):** Install the official client library via Composer:
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ code.file-hash {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* License Product Tab */
|
/* License Product Tab - Hidden by default, shown via JS based on product type */
|
||||||
#woocommerce-product-data .show_if_licensed {
|
#woocommerce-product-data .show_if_licensed,
|
||||||
display: block !important;
|
#woocommerce-product-data .show_if_licensed-variable {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#woocommerce-product-data .hide_if_licensed {
|
#woocommerce-product-data .hide_if_licensed {
|
||||||
|
|||||||
@@ -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);
|
||||||
domains: doms.filter(d => d),
|
const doms = newDomains[pKey] || [];
|
||||||
})).filter(item => item.domains.length > 0);
|
const entry = {
|
||||||
|
product_id: p.product_id,
|
||||||
|
domains: doms.filter(d => d),
|
||||||
|
};
|
||||||
|
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,35 +211,43 @@
|
|||||||
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 => {
|
||||||
'div',
|
const productKey = getProductKey(product);
|
||||||
{
|
const durationLabel = product.duration_label || '';
|
||||||
key: product.product_id,
|
const displayName = durationLabel
|
||||||
style: {
|
? `${product.name} (${durationLabel})`
|
||||||
marginBottom: '16px',
|
: product.name;
|
||||||
padding: '12px',
|
|
||||||
backgroundColor: '#fff',
|
return createElement(
|
||||||
borderRadius: '4px',
|
'div',
|
||||||
}
|
{
|
||||||
},
|
key: productKey,
|
||||||
createElement('strong', { style: { display: 'block', marginBottom: '8px' } },
|
style: {
|
||||||
product.name + (product.quantity > 1 ? ` (×${product.quantity})` : '')
|
marginBottom: '16px',
|
||||||
),
|
padding: '12px',
|
||||||
Array.from({ length: product.quantity }, (_, i) => {
|
backgroundColor: '#fff',
|
||||||
const key = `${product.product_id}_${i}`;
|
borderRadius: '4px',
|
||||||
return createElement(
|
}
|
||||||
'div',
|
},
|
||||||
{ key: i, style: { marginBottom: '8px' } },
|
createElement('strong', { style: { display: 'block', marginBottom: '8px' } },
|
||||||
createElement(TextControl, {
|
displayName + (product.quantity > 1 ? ` ×${product.quantity}` : '')
|
||||||
label: (settings.licenseLabel || __('License %d:', 'wc-licensed-product')).replace('%d', i + 1),
|
),
|
||||||
value: domains[product.product_id]?.[i] || '',
|
Array.from({ length: product.quantity }, (_, i) => {
|
||||||
onChange: (val) => handleChange(product.product_id, i, val),
|
const key = `${productKey}_${i}`;
|
||||||
placeholder: settings.fieldPlaceholder || 'example.com',
|
return createElement(
|
||||||
help: errors[key] || '',
|
'div',
|
||||||
})
|
{ key: i, style: { marginBottom: '8px' } },
|
||||||
);
|
createElement(TextControl, {
|
||||||
})
|
label: (settings.licenseLabel || __('License %d:', 'wc-licensed-product')).replace('%d', i + 1),
|
||||||
)),
|
value: domains[productKey]?.[i] || '',
|
||||||
|
onChange: (val) => handleChange(productKey, i, val),
|
||||||
|
placeholder: settings.fieldPlaceholder || 'example.com',
|
||||||
|
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
4
composer.lock
generated
@@ -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",
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
releases/wc-licensed-product-0.5.3.zip
Normal file
BIN
releases/wc-licensed-product-0.5.3.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.3.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.3.zip.sha256
Normal file
@@ -0,0 +1 @@
|
|||||||
|
bbd0fa8888c6990a4ba00ccfb8b2189ee6ac529a34cc11a5d8d8d28518b1f6dd wc-licensed-product-0.5.3.zip
|
||||||
BIN
releases/wc-licensed-product-0.5.5.zip
Normal file
BIN
releases/wc-licensed-product-0.5.5.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.5.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.5.zip.sha256
Normal file
@@ -0,0 +1 @@
|
|||||||
|
8c37e1c68eb6031c37d35adc516a492abdbea8498bdc3e3fc7d93eda380a4fe0 wc-licensed-product-0.5.5.zip
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'];
|
||||||
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
$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');
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<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'];
|
||||||
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
$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');
|
||||||
|
}
|
||||||
|
|
||||||
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'];
|
||||||
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
$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');
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have the LicensedProduct instance for type hints
|
||||||
|
if (!$settingsProduct instanceof LicensedProduct) {
|
||||||
|
$settingsProduct = new LicensedProduct($productId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we have the LicensedProduct instance for type hints
|
if (!$settingsProduct) {
|
||||||
if (!$product instanceof LicensedProduct) {
|
return null;
|
||||||
$product = new LicensedProduct($productId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -176,22 +202,28 @@ 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
|
// Show/hide panels based on product type
|
||||||
$('select#product-type').change(function() {
|
function toggleLicensedProductOptions() {
|
||||||
if ($(this).val() === 'licensed') {
|
var productType = $('#product-type').val();
|
||||||
|
var isLicensed = productType === 'licensed';
|
||||||
|
var isLicensedVariable = productType === 'licensed-variable';
|
||||||
|
|
||||||
|
if (isLicensed || isLicensedVariable) {
|
||||||
$('.show_if_licensed').show();
|
$('.show_if_licensed').show();
|
||||||
|
$('.show_if_licensed-variable').show();
|
||||||
$('.general_options').show();
|
$('.general_options').show();
|
||||||
$('.pricing').show();
|
$('.pricing').show();
|
||||||
|
$('.general_tab').show();
|
||||||
} else {
|
} else {
|
||||||
$('.show_if_licensed').hide();
|
$('.show_if_licensed').hide();
|
||||||
|
$('.show_if_licensed-variable').hide();
|
||||||
}
|
}
|
||||||
}).change();
|
}
|
||||||
|
|
||||||
// Show general tab for licensed products
|
// Initial state on page load
|
||||||
$('#product-type').on('change', function() {
|
toggleLicensedProductOptions();
|
||||||
if ($(this).val() === 'licensed') {
|
|
||||||
$('.general_tab').show();
|
// On product type change
|
||||||
}
|
$('#product-type').on('change', toggleLicensedProductOptions);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?php
|
<?php
|
||||||
@@ -236,9 +268,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 +292,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 +311,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 +328,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
196
src/Product/LicensedProductVariation.php
Normal file
196
src/Product/LicensedProductVariation.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/Product/LicensedVariableProduct.php
Normal file
151
src/Product/LicensedVariableProduct.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.6
|
||||||
* 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.6');
|
||||||
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__));
|
||||||
|
|||||||
Reference in New Issue
Block a user