5 Commits

Author SHA1 Message Date
c31df1e8c4 Add licensed variable product support for duration-based licenses (v0.5.3)
Customers can now purchase licenses with different durations (monthly,
yearly, lifetime) through WooCommerce product variations. Each variation
can have its own license validity settings.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:14:15 +01:00
8cac742f57 Update CLAUDE.md with v0.5.2 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:36:35 +01:00
41e46fc7b8 Bump version to 0.5.2 and update changelog
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:31:53 +01:00
549a58dc5d Add per-license customer secrets for API response verification
- Add static methods to ResponseSigner for deriving customer-specific secrets
- Display "API Verification Secret" in customer account licenses page
- Add collapsible secret section with copy button
- Update server-implementation.md with per-license secret documentation
- Update translations with new strings

Each customer now gets a unique verification secret derived from their
license key, eliminating the need to share the master server secret.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:29:57 +01:00
7d02105284 Update CLAUDE.md with v0.5.1 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:20:35 +01:00
22 changed files with 1754 additions and 333 deletions

View File

@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [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
### Added
- Per-license customer secrets for API response verification
- "API Verification Secret" section in customer account licenses page (collapsible)
- Copy button for customer secrets with clipboard support
- Documentation for per-license secret derivation and usage
### Security
- Customers no longer need the master server secret for signature verification
- Each license key has a unique derived secret using HKDF-like key derivation
- If one customer's secret is compromised, other customers remain unaffected
### Changed
- Updated `ResponseSigner` with static methods for secret derivation
- Updated `server-implementation.md` with per-license secret documentation
- Added new translation strings for secret-related UI
## [0.5.1] - 2026-01-26 ## [0.5.1] - 2026-01-26
### Fixed ### Fixed

126
CLAUDE.md
View File

@@ -32,18 +32,10 @@ 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.
### Known Bugs
No known bugs at the moment.
### Version 0.6.0 ### Version 0.6.0
*No planned features yet.* *No planned features yet.*
### Version 0.5.1
*No planned bugfixes yet.*
## Technical Stack ## Technical Stack
- **Language:** PHP 8.3.x - **Language:** PHP 8.3.x
@@ -1268,3 +1260,121 @@ Major feature release enabling customers to purchase multiple licenses for diffe
- Created release package: `releases/wc-licensed-product-0.5.0.zip` (863 KB) - Created release package: `releases/wc-licensed-product-0.5.0.zip` (863 KB)
- SHA256: `446804948e5f99d705b548061d5b78180856984c58458640a910ada8f27f5316` - SHA256: `446804948e5f99d705b548061d5b78180856984c58458640a910ada8f27f5316`
- Tagged as `v0.5.0` and pushed to `main` branch - Tagged as `v0.5.0` and pushed to `main` branch
### 2026-01-26 - Version 0.5.1 - Admin UI Fixes
**Overview:**
Bug fix release improving admin UI usability for version management and license overview.
**Bug Fixes:**
- Fixed: Product versions in admin now sort by version DESC when adding via AJAX
- Fixed: License actions in admin overview are now always visible (not just on hover)
**Modified files:**
- `assets/css/admin.css` - Added `!important` to `.licenses-table .row-actions` for permanent visibility
- `assets/js/versions.js` - Added `compareVersions()` function and sorted insertion for AJAX-added versions
**Technical notes:**
- Version sorting uses semantic version comparison (major.minor.patch)
- New versions are inserted in correct sorted position in the table instead of always appending
- CSS override uses `!important` to overcome WordPress default hover-only behavior for row actions
- `compareVersions()` function compares version strings numerically (1.10.0 > 1.9.0)
**Release v0.5.1:**
- Created release package: `releases/wc-licensed-product-0.5.1.zip` (863 KB)
- SHA256: `a489f0b8cfcd7d5d9b2021b7ff581b9f1a56468dfde87bbb06bb4555d11f7556`
- 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

View File

@@ -863,3 +863,118 @@
color: #2271b1; color: #2271b1;
font-weight: 500; font-weight: 500;
} }
/* Customer Secret Section */
.license-row-secret {
margin-top: 0.75em;
padding-top: 0.75em;
border-top: 1px dashed #e5e5e5;
}
.secret-toggle {
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0.4em 0.75em;
background: transparent;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.85em;
color: #666;
cursor: pointer;
transition: all 0.2s ease;
}
.secret-toggle:hover {
background: #f5f5f5;
border-color: #ccc;
color: #333;
}
.secret-toggle .dashicons {
font-size: 14px;
width: 14px;
height: 14px;
}
.secret-toggle .toggle-arrow {
transition: transform 0.2s ease;
}
.secret-toggle[aria-expanded="true"] .toggle-arrow {
transform: rotate(180deg);
}
.secret-content {
margin-top: 0.75em;
padding: 1em;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #e5e5e5;
}
.secret-description {
margin: 0 0 0.75em 0;
font-size: 0.85em;
color: #666;
}
.secret-value-wrapper {
display: flex;
align-items: center;
gap: 0.5em;
}
.secret-value {
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 0.75em;
background: #fff;
padding: 0.5em 0.75em;
border: 1px solid #ddd;
border-radius: 4px;
word-break: break-all;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.copy-secret-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.copy-secret-btn:hover {
background: #e5e5e5;
border-color: #ccc;
}
.copy-secret-btn .dashicons {
font-size: 18px;
width: 18px;
height: 18px;
}
@media screen and (max-width: 768px) {
.secret-value-wrapper {
flex-direction: column;
align-items: stretch;
}
.secret-value {
font-size: 0.7em;
}
.copy-secret-btn {
align-self: flex-start;
}
}

View File

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

View File

@@ -19,6 +19,7 @@
bindEvents: function() { bindEvents: function() {
$(document).on('click', '.copy-license-btn', this.copyLicenseKey); $(document).on('click', '.copy-license-btn', this.copyLicenseKey);
$(document).on('click', '.copy-secret-btn', this.copySecret);
// Transfer modal events // Transfer modal events
$(document).on('click', '.wclp-transfer-btn', this.openTransferModal.bind(this)); $(document).on('click', '.wclp-transfer-btn', this.openTransferModal.bind(this));
@@ -28,6 +29,9 @@
// Older versions toggle // Older versions toggle
$(document).on('click', '.older-versions-toggle', this.toggleOlderVersions); $(document).on('click', '.older-versions-toggle', this.toggleOlderVersions);
// Secret toggle
$(document).on('click', '.secret-toggle', this.toggleSecret);
// Close modal on escape key // Close modal on escape key
$(document).on('keyup', function(e) { $(document).on('keyup', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -50,6 +54,47 @@
$list.slideToggle(200); $list.slideToggle(200);
}, },
/**
* Toggle secret visibility
*/
toggleSecret: function(e) {
e.preventDefault();
var $btn = $(this);
var $content = $btn.siblings('.secret-content');
var isExpanded = $btn.attr('aria-expanded') === 'true';
$btn.attr('aria-expanded', !isExpanded);
$content.slideToggle(200);
},
/**
* Copy secret to clipboard
*/
copySecret: function(e) {
e.preventDefault();
var $btn = $(this);
var secret = $btn.data('secret');
if (!secret) {
return;
}
// Use modern clipboard API if available
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(secret)
.then(function() {
WCLicensedProductFrontend.showCopyFeedback($btn, true);
})
.catch(function() {
WCLicensedProductFrontend.fallbackCopy(secret, $btn);
});
} else {
WCLicensedProductFrontend.fallbackCopy(secret, $btn);
}
},
/** /**
* Copy license key to clipboard * Copy license key to clipboard
*/ */

12
composer.lock generated
View File

@@ -380,16 +380,16 @@
}, },
{ {
"name": "symfony/http-client", "name": "symfony/http-client",
"version": "v7.4.3", "version": "v7.4.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-client.git", "url": "https://github.com/symfony/http-client.git",
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616" "reference": "d63c23357d74715a589454c141c843f0172bec6c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616", "url": "https://api.github.com/repos/symfony/http-client/zipball/d63c23357d74715a589454c141c843f0172bec6c",
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616", "reference": "d63c23357d74715a589454c141c843f0172bec6c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -457,7 +457,7 @@
"http" "http"
], ],
"support": { "support": {
"source": "https://github.com/symfony/http-client/tree/v7.4.3" "source": "https://github.com/symfony/http-client/tree/v7.4.4"
}, },
"funding": [ "funding": [
{ {
@@ -477,7 +477,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-23T14:50:43+00:00" "time": "2026-01-23T16:34:22+00:00"
}, },
{ {
"name": "symfony/http-client-contracts", "name": "symfony/http-client-contracts",

View File

@@ -8,14 +8,16 @@ The security model works as follows:
1. Server generates a unique signature for each response using HMAC-SHA256 1. Server generates a unique signature for each response using HMAC-SHA256
2. Signature includes a timestamp to prevent replay attacks 2. Signature includes a timestamp to prevent replay attacks
3. Client verifies the signature using a shared secret 3. Each license key has a unique derived secret (not the master secret)
4. Invalid signatures cause the client to reject the response 4. Client verifies the signature using their per-license secret
5. Invalid signatures cause the client to reject the response
This prevents attackers from: This prevents attackers from:
- Faking valid license responses - Faking valid license responses
- Replaying old responses - Replaying old responses
- Tampering with response data - Tampering with response data
- Using one customer's secret to verify another customer's responses
## Requirements ## Requirements
@@ -323,13 +325,49 @@ Adjust if needed:
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes $signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes
``` ```
### Per-License Secrets
Each customer receives a unique secret derived from their license key. This means:
- Customers only know their own secret, not the master server secret
- If one customer's secret is leaked, other customers are not affected
- The server uses HKDF-like derivation to create unique secrets
#### How Customers Get Their Secret
Customers can find their per-license verification secret in their account:
1. Log in to the store
2. Go to My Account > Licenses
3. Click "API Verification Secret" under any license
4. Copy the 64-character hex string
This secret is automatically derived from the customer's license key and the server's master secret.
#### Using the Customer Secret
```php
use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Symfony\Component\HttpClient\HttpClient;
// Customer uses their per-license secret (from account page)
$client = new SecureLicenseClient(
httpClient: HttpClient::create(),
baseUrl: 'https://shop.example.com',
serverSecret: 'customer-secret-from-account-page', // 64 hex chars
);
$info = $client->validate('XXXX-XXXX-XXXX-XXXX', 'example.com');
```
### Secret Key Rotation ### Secret Key Rotation
To rotate the server secret: To rotate the server secret:
1. Deploy new secret to server 1. Deploy new secret to server
2. Update client configurations 2. All per-license secrets change automatically (they're derived)
3. Old signatures become invalid immediately 3. Customers must copy their new secret from their account page
4. Old signatures become invalid immediately
For zero-downtime rotation, implement versioned secrets: For zero-downtime rotation, implement versioned secrets:

View File

@@ -4,8 +4,8 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: WC Licensed Product 0.5.0\n" "Project-Id-Version: WC Licensed Product 0.5.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: magdev3.0@gmail.com\n"
"POT-Creation-Date: 2026-01-25 18:32+0100\n" "POT-Creation-Date: 2026-01-26 16:08+0100\n"
"PO-Revision-Date: 2026-01-25T18:30:00+00:00\n" "PO-Revision-Date: 2026-01-25T18:30:00+00:00\n"
"Last-Translator: Marco Graetsch <magdev3.0@gmail.com>\n" "Last-Translator: Marco Graetsch <magdev3.0@gmail.com>\n"
"Language-Team: German (Switzerland) <de_CH@li.org>\n" "Language-Team: German (Switzerland) <de_CH@li.org>\n"
@@ -248,7 +248,7 @@ msgstr "Version erfolgreich aktualisiert."
#: src/Admin/AdminController.php:90 src/Admin/AdminController.php:1200 #: src/Admin/AdminController.php:90 src/Admin/AdminController.php:1200
#: src/Admin/OrderLicenseController.php:149 #: src/Admin/OrderLicenseController.php:149
#: src/Admin/OrderLicenseController.php:281 #: src/Admin/OrderLicenseController.php:281
#: src/Frontend/AccountController.php:90 #: src/Frontend/AccountController.php:91
msgid "Licenses" msgid "Licenses"
msgstr "Lizenzen" msgstr "Lizenzen"
@@ -295,7 +295,7 @@ msgstr "Bearbeiten"
#: src/Admin/AdminController.php:149 src/Admin/AdminController.php:1341 #: src/Admin/AdminController.php:149 src/Admin/AdminController.php:1341
#: src/Admin/AdminController.php:1361 src/Admin/AdminController.php:1382 #: src/Admin/AdminController.php:1361 src/Admin/AdminController.php:1382
#: src/Admin/AdminController.php:1537 src/Admin/OrderLicenseController.php:244 #: src/Admin/AdminController.php:1537 src/Admin/OrderLicenseController.php:244
#: src/Frontend/AccountController.php:384 #: src/Frontend/AccountController.php:387
msgid "Cancel" msgid "Cancel"
msgstr "Abbrechen" msgstr "Abbrechen"
@@ -310,17 +310,19 @@ msgstr "Speichern"
#: src/Admin/AdminController.php:1373 src/Admin/AdminController.php:1613 #: src/Admin/AdminController.php:1373 src/Admin/AdminController.php:1613
#: src/Admin/DashboardWidgetController.php:136 #: src/Admin/DashboardWidgetController.php:136
#: src/Admin/OrderLicenseController.php:260 #: src/Admin/OrderLicenseController.php:260
#: src/Admin/SettingsController.php:192 src/Product/LicensedProductType.php:110 #: src/Admin/SettingsController.php:192 src/Product/LicensedProductType.php:136
#: src/Product/LicensedProductType.php:158 #: src/Product/LicensedProductType.php:184
#: src/Frontend/AccountController.php:283 #: src/Product/LicensedProductType.php:379
#: src/Product/LicensedProductVariation.php:139
#: src/Frontend/AccountController.php:286
msgid "Lifetime" msgid "Lifetime"
msgstr "Lebenslang" msgstr "Lebenslang"
#: src/Admin/AdminController.php:152 src/Frontend/AccountController.php:422 #: src/Admin/AdminController.php:152 src/Frontend/AccountController.php:425
msgid "Copied!" msgid "Copied!"
msgstr "Kopiert!" msgstr "Kopiert!"
#: src/Admin/AdminController.php:153 src/Frontend/AccountController.php:423 #: src/Admin/AdminController.php:153 src/Frontend/AccountController.php:426
msgid "Copy failed" msgid "Copy failed"
msgstr "Kopieren fehlgeschlagen" msgstr "Kopieren fehlgeschlagen"
@@ -408,7 +410,7 @@ msgstr "Lizenzschlüssel und Domain sind erforderlich."
#: src/Admin/AdminController.php:531 src/Admin/AdminController.php:549 #: src/Admin/AdminController.php:531 src/Admin/AdminController.php:549
#: src/Admin/AdminController.php:577 src/Admin/AdminController.php:621 #: src/Admin/AdminController.php:577 src/Admin/AdminController.php:621
#: src/Admin/AdminController.php:811 src/Admin/SettingsController.php:469 #: src/Admin/AdminController.php:811 src/Admin/SettingsController.php:469
#: src/Frontend/AccountController.php:439 #: src/Frontend/AccountController.php:442
msgid "Security check failed." msgid "Security check failed."
msgstr "Sicherheitsüberprüfung fehlgeschlagen." msgstr "Sicherheitsüberprüfung fehlgeschlagen."
@@ -677,8 +679,8 @@ msgstr "Kunde"
#: src/Admin/AdminController.php:1294 src/Admin/AdminController.php:1445 #: src/Admin/AdminController.php:1294 src/Admin/AdminController.php:1445
#: src/Admin/AdminController.php:1495 src/Admin/OrderLicenseController.php:205 #: src/Admin/AdminController.php:1495 src/Admin/OrderLicenseController.php:205
#: src/Checkout/CheckoutBlocksIntegration.php:129 #: src/Checkout/CheckoutBlocksIntegration.php:130
#: src/Checkout/CheckoutController.php:122 #: src/Checkout/CheckoutController.php:161
#: src/Email/LicenseEmailController.php:288 #: src/Email/LicenseEmailController.php:288
msgid "Domain" msgid "Domain"
msgstr "Domain" msgstr "Domain"
@@ -698,7 +700,7 @@ msgstr "Läuft ab"
msgid "No licenses found." msgid "No licenses found."
msgstr "Keine Lizenzen gefunden." msgstr "Keine Lizenzen gefunden."
#: src/Admin/AdminController.php:1314 src/Frontend/AccountController.php:260 #: src/Admin/AdminController.php:1314 src/Frontend/AccountController.php:263
msgid "Copy to clipboard" msgid "Copy to clipboard"
msgstr "In Zwischenablage kopieren" msgstr "In Zwischenablage kopieren"
@@ -718,7 +720,7 @@ msgstr "Lizenz gegen API testen"
msgid "Test" msgid "Test"
msgstr "Testen" msgstr "Testen"
#: src/Admin/AdminController.php:1400 src/Frontend/AccountController.php:267 #: src/Admin/AdminController.php:1400 src/Frontend/AccountController.php:270
msgid "Transfer to new domain" msgid "Transfer to new domain"
msgstr "Auf neue Domain übertragen" msgstr "Auf neue Domain übertragen"
@@ -746,27 +748,27 @@ msgstr "Lizenzvalidierungstest"
msgid "Testing license..." msgid "Testing license..."
msgstr "Lizenz wird geprüft..." msgstr "Lizenz wird geprüft..."
#: src/Admin/AdminController.php:1508 src/Frontend/AccountController.php:362 #: src/Admin/AdminController.php:1508 src/Frontend/AccountController.php:365
msgid "Close" msgid "Close"
msgstr "Schliessen" msgstr "Schliessen"
#: src/Admin/AdminController.php:1517 src/Frontend/AccountController.php:363 #: src/Admin/AdminController.php:1517 src/Frontend/AccountController.php:366
msgid "Transfer License to New Domain" msgid "Transfer License to New Domain"
msgstr "Lizenz auf neue Domain übertragen" msgstr "Lizenz auf neue Domain übertragen"
#: src/Admin/AdminController.php:1524 src/Frontend/AccountController.php:368 #: src/Admin/AdminController.php:1524 src/Frontend/AccountController.php:371
msgid "Current Domain" msgid "Current Domain"
msgstr "Aktuelle Domain" msgstr "Aktuelle Domain"
#: src/Admin/AdminController.php:1528 src/Frontend/AccountController.php:373 #: src/Admin/AdminController.php:1528 src/Frontend/AccountController.php:376
msgid "New Domain" msgid "New Domain"
msgstr "Neue Domain" msgstr "Neue Domain"
#: src/Admin/AdminController.php:1531 src/Frontend/AccountController.php:377 #: src/Admin/AdminController.php:1531 src/Frontend/AccountController.php:380
msgid "Enter the new domain without http:// or www." msgid "Enter the new domain without http:// or www."
msgstr "Geben Sie die neue Domain ohne http:// oder www ein." msgstr "Geben Sie die neue Domain ohne http:// oder www ein."
#: src/Admin/AdminController.php:1536 src/Frontend/AccountController.php:382 #: src/Admin/AdminController.php:1536 src/Frontend/AccountController.php:385
msgid "Transfer License" msgid "Transfer License"
msgstr "Lizenz übertragen" msgstr "Lizenz übertragen"
@@ -954,11 +956,11 @@ msgid "Domains specified during checkout (multi-domain order)."
msgstr "Bei der Bestellung angegebene Domains (Multi-Domain-Bestellung)." msgstr "Bei der Bestellung angegebene Domains (Multi-Domain-Bestellung)."
#: src/Admin/OrderLicenseController.php:119 #: src/Admin/OrderLicenseController.php:119
#: src/Checkout/CheckoutController.php:436 #: src/Checkout/CheckoutController.php:530
#: src/Checkout/CheckoutController.php:486 #: src/Checkout/CheckoutController.php:591
#: src/Checkout/CheckoutController.php:496 src/License/LicenseManager.php:806 #: src/Checkout/CheckoutController.php:613 src/License/LicenseManager.php:878
#: src/Product/VersionManager.php:349 src/Product/VersionManager.php:361 #: src/Product/VersionManager.php:349 src/Product/VersionManager.php:361
#: src/Frontend/AccountController.php:146 #: src/Frontend/AccountController.php:148
#: src/Email/LicenseExpirationEmail.php:107 #: src/Email/LicenseExpirationEmail.php:107
#: src/Email/LicenseExpiredEmail.php:99 #: src/Email/LicenseExpiredEmail.php:99
msgid "Unknown Product" msgid "Unknown Product"
@@ -973,10 +975,10 @@ msgstr ""
"automatisch bestehende Lizenz-Domains." "automatisch bestehende Lizenz-Domains."
#: src/Admin/OrderLicenseController.php:137 #: src/Admin/OrderLicenseController.php:137
#: src/Checkout/CheckoutBlocksIntegration.php:83 #: src/Checkout/CheckoutBlocksIntegration.php:84
#: src/Checkout/CheckoutBlocksIntegration.php:119 #: src/Checkout/CheckoutBlocksIntegration.php:120
#: src/Checkout/CheckoutController.php:130 #: src/Checkout/CheckoutController.php:169
#: src/Checkout/CheckoutController.php:186 #: src/Checkout/CheckoutController.php:235
msgid "example.com" msgid "example.com"
msgstr "beispiel.ch" msgstr "beispiel.ch"
@@ -1044,9 +1046,9 @@ msgid "Error. Please try again."
msgstr "Fehler. Bitte versuchen Sie es erneut." msgstr "Fehler. Bitte versuchen Sie es erneut."
#: src/Admin/OrderLicenseController.php:373 #: src/Admin/OrderLicenseController.php:373
#: src/Checkout/CheckoutBlocksIntegration.php:126 #: src/Checkout/CheckoutBlocksIntegration.php:127
#: src/Frontend/AccountController.php:427 #: src/Frontend/AccountController.php:430
#: src/Frontend/AccountController.php:459 #: src/Frontend/AccountController.php:462
msgid "Please enter a valid domain." msgid "Please enter a valid domain."
msgstr "Bitte geben Sie eine gültige Domain ein." msgstr "Bitte geben Sie eine gültige Domain ein."
@@ -1070,7 +1072,7 @@ msgstr "Bestellungs-Domain aktualisiert."
#: src/Admin/OrderLicenseController.php:449 #: src/Admin/OrderLicenseController.php:449
#: src/Frontend/DownloadController.php:117 #: src/Frontend/DownloadController.php:117
#: src/Frontend/AccountController.php:465 #: src/Frontend/AccountController.php:468
msgid "License not found." msgid "License not found."
msgstr "Lizenz nicht gefunden." msgstr "Lizenz nicht gefunden."
@@ -1285,7 +1287,7 @@ msgid "Too many requests. Please try again later."
msgstr "Zu viele Anfragen. Bitte versuchen Sie es später erneut." msgstr "Zu viele Anfragen. Bitte versuchen Sie es später erneut."
#: src/Api/RestApiController.php:345 src/Api/RestApiController.php:378 #: src/Api/RestApiController.php:345 src/Api/RestApiController.php:378
#: src/License/LicenseManager.php:403 #: src/License/LicenseManager.php:475
msgid "License key not found." msgid "License key not found."
msgstr "Lizenzschlüssel nicht gefunden." msgstr "Lizenzschlüssel nicht gefunden."
@@ -1309,69 +1311,69 @@ msgstr "Lizenz konnte nicht aktiviert werden."
msgid "License activated successfully." msgid "License activated successfully."
msgstr "Lizenz erfolgreich aktiviert." msgstr "Lizenz erfolgreich aktiviert."
#: src/Checkout/CheckoutBlocksIntegration.php:78 #: src/Checkout/CheckoutBlocksIntegration.php:79
#: src/Checkout/CheckoutBlocksIntegration.php:125 #: src/Checkout/CheckoutBlocksIntegration.php:126
#: src/Checkout/CheckoutController.php:119 #: src/Checkout/CheckoutController.php:158
msgid "License Domain" msgid "License Domain"
msgstr "Lizenz-Domain" msgstr "Lizenz-Domain"
#: src/Checkout/CheckoutBlocksIntegration.php:85 #: src/Checkout/CheckoutBlocksIntegration.php:86
msgid "Enter a valid domain (without http:// or www)" msgid "Enter a valid domain (without http:// or www)"
msgstr "Geben Sie eine gültige Domain ein (ohne http:// oder www)" msgstr "Geben Sie eine gültige Domain ein (ohne http:// oder www)"
#: src/Checkout/CheckoutBlocksIntegration.php:121 #: src/Checkout/CheckoutBlocksIntegration.php:122
#: src/Checkout/CheckoutController.php:150 #: src/Checkout/CheckoutController.php:189
msgid "Enter a unique domain for each license (without http:// or www)." msgid "Enter a unique domain for each license (without http:// or www)."
msgstr "" msgstr ""
"Geben Sie für jede Lizenz eine eindeutige Domain ein (ohne http:// oder www)." "Geben Sie für jede Lizenz eine eindeutige Domain ein (ohne http:// oder www)."
#: src/Checkout/CheckoutBlocksIntegration.php:122 #: src/Checkout/CheckoutBlocksIntegration.php:123
#: src/Checkout/CheckoutController.php:134 #: src/Checkout/CheckoutController.php:173
msgid "" msgid ""
"Enter the domain where you will use the license (without http:// or www)." "Enter the domain where you will use the license (without http:// or www)."
msgstr "" msgstr ""
"Geben Sie die Domain ein, auf der Sie die Lizenz verwenden möchten (ohne " "Geben Sie die Domain ein, auf der Sie die Lizenz verwenden möchten (ohne "
"http:// oder www)." "http:// oder www)."
#: src/Checkout/CheckoutBlocksIntegration.php:124 #: src/Checkout/CheckoutBlocksIntegration.php:125
#: src/Checkout/CheckoutController.php:148 #: src/Checkout/CheckoutController.php:187
msgid "License Domains" msgid "License Domains"
msgstr "Lizenz-Domains" msgstr "Lizenz-Domains"
#: src/Checkout/CheckoutBlocksIntegration.php:127 #: src/Checkout/CheckoutBlocksIntegration.php:128
msgid "Each license requires a unique domain." msgid "Each license requires a unique domain."
msgstr "Jede Lizenz erfordert eine eindeutige Domain." msgstr "Jede Lizenz erfordert eine eindeutige Domain."
#: src/Checkout/CheckoutBlocksIntegration.php:128 #: src/Checkout/CheckoutBlocksIntegration.php:129
#: src/Checkout/CheckoutController.php:175 #: src/Checkout/CheckoutController.php:224
#, php-format #, php-format
msgid "License %d:" msgid "License %d:"
msgstr "Lizenz %d:" msgstr "Lizenz %d:"
#: src/Checkout/CheckoutController.php:123 #: src/Checkout/CheckoutController.php:162
#: src/Checkout/CheckoutController.php:179 #: src/Checkout/CheckoutController.php:228
msgid "required" msgid "required"
msgstr "erforderlich" msgstr "erforderlich"
#: src/Checkout/CheckoutController.php:258 #: src/Checkout/CheckoutController.php:323
msgid "Please enter a domain for your license." msgid "Please enter a domain for your license."
msgstr "Bitte geben Sie eine Domain für Ihre Lizenz ein." msgstr "Bitte geben Sie eine Domain für Ihre Lizenz ein."
#: src/Checkout/CheckoutController.php:264 #: src/Checkout/CheckoutController.php:329
msgid "Please enter a valid domain for your license." msgid "Please enter a valid domain for your license."
msgstr "Bitte geben Sie eine gültige Domain für Ihre Lizenz ein." msgstr "Bitte geben Sie eine gültige Domain für Ihre Lizenz ein."
#: src/Checkout/CheckoutController.php:287 #: src/Checkout/CheckoutController.php:356
#, php-format #, php-format
msgid "Please enter a domain for %1$s (License %2$d)." msgid "Please enter a domain for %1$s (License %2$d)."
msgstr "Bitte geben Sie eine Domain für %1$s (Lizenz %2$d) ein." msgstr "Bitte geben Sie eine Domain für %1$s (Lizenz %2$d) ein."
#: src/Checkout/CheckoutController.php:302 #: src/Checkout/CheckoutController.php:371
#, php-format #, php-format
msgid "Please enter a valid domain for %1$s (License %2$d)." msgid "Please enter a valid domain for %1$s (License %2$d)."
msgstr "Bitte geben Sie eine gültige Domain für %1$s (Lizenz %2$d) ein." msgstr "Bitte geben Sie eine gültige Domain für %1$s (Lizenz %2$d) ein."
#: src/Checkout/CheckoutController.php:316 #: src/Checkout/CheckoutController.php:385
#, php-format #, php-format
msgid "" msgid ""
"The domain \"%1$s\" is used multiple times for %2$s. Each license requires a " "The domain \"%1$s\" is used multiple times for %2$s. Each license requires a "
@@ -1380,23 +1382,29 @@ msgstr ""
"Die Domain \"%1$s\" wird mehrfach für %2$s verwendet. Jede Lizenz erfordert " "Die Domain \"%1$s\" wird mehrfach für %2$s verwendet. Jede Lizenz erfordert "
"eine eindeutige Domain." "eine eindeutige Domain."
#: src/Checkout/CheckoutController.php:419 #: src/Checkout/CheckoutController.php:500
#: src/Checkout/CheckoutController.php:466 #: src/Checkout/CheckoutController.php:561
#: src/Checkout/CheckoutController.php:470 #: src/Checkout/CheckoutController.php:565
msgid "License Domain:" msgid "License Domain:"
msgstr "Lizenz-Domain:" msgstr "Lizenz-Domain:"
#: src/Checkout/CheckoutController.php:432 #: src/Checkout/CheckoutController.php:513
#: src/Checkout/CheckoutController.php:483 #: src/Checkout/CheckoutController.php:578
#: src/Checkout/CheckoutController.php:492 #: src/Checkout/CheckoutController.php:599
msgid "License Domains:" msgid "License Domains:"
msgstr "Lizenz-Domains:" msgstr "Lizenz-Domains:"
#: src/Checkout/CheckoutController.php:522
#: src/Checkout/CheckoutController.php:585
#: src/Checkout/CheckoutController.php:607
msgid "Unknown Variation"
msgstr "Unbekannte Variante"
#: src/Checkout/StoreApiExtension.php:93 #: src/Checkout/StoreApiExtension.php:93
msgid "Domains for license activation by product" msgid "Domains for license activation by product"
msgstr "Domains für Lizenz-Aktivierung nach Produkt" msgstr "Domains für Lizenz-Aktivierung nach Produkt"
#: src/Checkout/StoreApiExtension.php:117 #: src/Checkout/StoreApiExtension.php:120
msgid "Domain for license activation" msgid "Domain for license activation"
msgstr "Domain für Lizenz-Aktivierung" msgstr "Domain für Lizenz-Aktivierung"
@@ -1408,67 +1416,73 @@ msgstr "Lizenzeinstellungen nicht konfiguriert."
msgid "Could not connect to license server." msgid "Could not connect to license server."
msgstr "Verbindung zum Lizenzserver konnte nicht hergestellt werden." msgstr "Verbindung zum Lizenzserver konnte nicht hergestellt werden."
#: src/License/LicenseManager.php:412 #: src/License/LicenseManager.php:484
msgid "This license has been revoked." msgid "This license has been revoked."
msgstr "Diese Lizenz wurde widerrufen." msgstr "Diese Lizenz wurde widerrufen."
#: src/License/LicenseManager.php:422 #: src/License/LicenseManager.php:494
msgid "This license has expired." msgid "This license has expired."
msgstr "Diese Lizenz ist abgelaufen." msgstr "Diese Lizenz ist abgelaufen."
#: src/License/LicenseManager.php:430 #: src/License/LicenseManager.php:502
msgid "This license is inactive." msgid "This license is inactive."
msgstr "Diese Lizenz ist inaktiv." msgstr "Diese Lizenz ist inaktiv."
#: src/License/LicenseManager.php:440 #: src/License/LicenseManager.php:512
msgid "This license is not valid for this domain." msgid "This license is not valid for this domain."
msgstr "Diese Lizenz ist für diese Domain nicht gültig." msgstr "Diese Lizenz ist für diese Domain nicht gültig."
#: src/Product/LicensedProductType.php:61 #: src/Product/LicensedProductType.php:72
msgid "Licensed Product" msgid "Licensed Product"
msgstr "Lizensiertes Produkt" msgstr "Lizensiertes Produkt"
#: src/Product/LicensedProductType.php:82 #: src/Product/LicensedProductType.php:73
msgid "Licensed Variable Product"
msgstr "Lizensiertes variables Produkt"
#: src/Product/LicensedProductType.php:108
msgid "License Settings" msgid "License Settings"
msgstr "Lizenz-Einstellungen" msgstr "Lizenz-Einstellungen"
#: src/Product/LicensedProductType.php:109 #: src/Product/LicensedProductType.php:135
#: src/Product/LicensedProductType.php:378
#, php-format #, php-format
msgid "%d days" msgid "%d days"
msgstr "%d Tage" msgstr "%d Tage"
#: src/Product/LicensedProductType.php:119 #: src/Product/LicensedProductType.php:145
#, php-format #, php-format
msgid "Leave fields empty to use default settings from %s." msgid "Leave fields empty to use default settings from %s."
msgstr "Felder leer lassen, um Standardeinstellungen von %s zu verwenden." msgstr "Felder leer lassen, um Standardeinstellungen von %s zu verwenden."
#: src/Product/LicensedProductType.php:121 #: src/Product/LicensedProductType.php:147
msgid "WooCommerce > Settings > Licensed Products" msgid "WooCommerce > Settings > Licensed Products"
msgstr "WooCommerce > Einstellungen > Lizensierte Produkte" msgstr "WooCommerce > Einstellungen > Lizensierte Produkte"
#: src/Product/LicensedProductType.php:128 #: src/Product/LicensedProductType.php:154
#: src/Product/LicensedProductType.php:396
msgid "Max Activations" msgid "Max Activations"
msgstr "Max. Aktivierungen" msgstr "Max. Aktivierungen"
#: src/Product/LicensedProductType.php:131 #: src/Product/LicensedProductType.php:157
#, php-format #, php-format
msgid "Maximum number of domain activations per license. Default: %d" msgid "Maximum number of domain activations per license. Default: %d"
msgstr "Maximale Anzahl der Domain-Aktivierungen pro Lizenz. Standard: %d" msgstr "Maximale Anzahl der Domain-Aktivierungen pro Lizenz. Standard: %d"
#: src/Product/LicensedProductType.php:146 #: src/Product/LicensedProductType.php:172
msgid "License Validity (Days)" msgid "License Validity (Days)"
msgstr "Lizenz-Gültigkeit (Tage)" msgstr "Lizenz-Gültigkeit (Tage)"
#: src/Product/LicensedProductType.php:149 #: src/Product/LicensedProductType.php:175
#, php-format #, php-format
msgid "Number of days the license is valid. Leave empty for default (%s)." msgid "Number of days the license is valid. Leave empty for default (%s)."
msgstr "Anzahl Tage, die die Lizenz gültig ist. Leer lassen für Standard (%s)." msgstr "Anzahl Tage, die die Lizenz gültig ist. Leer lassen für Standard (%s)."
#: src/Product/LicensedProductType.php:164 #: src/Product/LicensedProductType.php:190
msgid "Bind to Major Version" msgid "Bind to Major Version"
msgstr "An Hauptversion binden" msgstr "An Hauptversion binden"
#: src/Product/LicensedProductType.php:167 #: src/Product/LicensedProductType.php:193
#, php-format #, php-format
msgid "" msgid ""
"If enabled, licenses are bound to the major version at purchase time. " "If enabled, licenses are bound to the major version at purchase time. "
@@ -1477,18 +1491,38 @@ msgstr ""
"Falls aktiviert, werden Lizenzen an die Hauptversion zum Kaufzeitpunkt " "Falls aktiviert, werden Lizenzen an die Hauptversion zum Kaufzeitpunkt "
"gebunden. Standard: %s" "gebunden. Standard: %s"
#: src/Product/LicensedProductType.php:168 #: src/Product/LicensedProductType.php:194
msgid "Yes" msgid "Yes"
msgstr "Ja" msgstr "Ja"
#: src/Product/LicensedProductType.php:168 #: src/Product/LicensedProductType.php:194
msgid "No" msgid "No"
msgstr "Nein" msgstr "Nein"
#: src/Product/LicensedProductType.php:288 #: src/Product/LicensedProductType.php:321
msgid "Version:" msgid "Version:"
msgstr "Version:" msgstr "Version:"
#: src/Product/LicensedProductType.php:349
msgid "Licensed products are always virtual"
msgstr "Lizenzierte Produkte sind immer virtuell"
#: src/Product/LicensedProductType.php:351
msgid "Virtual"
msgstr "Virtuell"
#: src/Product/LicensedProductType.php:384
msgid "License Duration (Days)"
msgstr "Lizenz-Gültigkeit (Tage)"
#: src/Product/LicensedProductType.php:393
msgid "Leave empty for parent default. 0 = Lifetime."
msgstr "Leer lassen für übergeordneten Standard. 0 = Lebenslang."
#: src/Product/LicensedProductType.php:405
msgid "Leave empty for parent default."
msgstr "Leer lassen für übergeordneten Standard."
#: src/Product/VersionManager.php:166 #: src/Product/VersionManager.php:166
msgid "Attachment file not found." msgid "Attachment file not found."
msgstr "Anhangs-Datei nicht gefunden." msgstr "Anhangs-Datei nicht gefunden."
@@ -1498,6 +1532,25 @@ msgstr "Anhangs-Datei nicht gefunden."
msgid "File checksum does not match. Expected: %1$s, Got: %2$s" msgid "File checksum does not match. Expected: %1$s, Got: %2$s"
msgstr "Datei-Prüfsumme stimmt nicht überein. Erwartet: %1$s, Erhalten: %2$s" msgstr "Datei-Prüfsumme stimmt nicht überein. Erwartet: %1$s, Erhalten: %2$s"
#: src/Product/LicensedProductVariation.php:143
msgid "Monthly"
msgstr "Monatlich"
#: src/Product/LicensedProductVariation.php:147
msgid "Quarterly"
msgstr "Vierteljährlich"
#: src/Product/LicensedProductVariation.php:151
msgid "Yearly"
msgstr "Jährlich"
#: src/Product/LicensedProductVariation.php:156
#, php-format
msgid "%d day"
msgid_plural "%d days"
msgstr[0] "%d Tag"
msgstr[1] "%d Tage"
#: src/Frontend/DownloadController.php:77 #: src/Frontend/DownloadController.php:77
#: src/Frontend/DownloadController.php:101 #: src/Frontend/DownloadController.php:101
msgid "Invalid download link." msgid "Invalid download link."
@@ -1549,48 +1602,48 @@ msgstr "Keine Download-Datei für diese Version verfügbar."
msgid "Download file not found." msgid "Download file not found."
msgstr "Download-Datei nicht gefunden." msgstr "Download-Datei nicht gefunden."
#: src/Frontend/AccountController.php:104 #: src/Frontend/AccountController.php:105
msgid "Please log in to view your licenses." msgid "Please log in to view your licenses."
msgstr "Bitte melden Sie sich an, um Ihre Lizenzen zu sehen." msgstr "Bitte melden Sie sich an, um Ihre Lizenzen zu sehen."
#: src/Frontend/AccountController.php:220 #: src/Frontend/AccountController.php:223
msgid "You have no licenses yet." msgid "You have no licenses yet."
msgstr "Sie haben noch keine Lizenzen." msgstr "Sie haben noch keine Lizenzen."
#: src/Frontend/AccountController.php:242 #: src/Frontend/AccountController.php:245
#, php-format #, php-format
msgid "Order #%s" msgid "Order #%s"
msgstr "Bestellung #%s" msgstr "Bestellung #%s"
#: src/Frontend/AccountController.php:293 #: src/Frontend/AccountController.php:296
msgid "Available Downloads" msgid "Available Downloads"
msgstr "Verfügbare Downloads" msgstr "Verfügbare Downloads"
#: src/Frontend/AccountController.php:302 #: src/Frontend/AccountController.php:305
#: src/Frontend/AccountController.php:335 #: src/Frontend/AccountController.php:338
#, php-format #, php-format
msgid "Version %s" msgid "Version %s"
msgstr "Version %s" msgstr "Version %s"
#: src/Frontend/AccountController.php:304 #: src/Frontend/AccountController.php:307
msgid "Latest" msgid "Latest"
msgstr "Neueste" msgstr "Neueste"
#: src/Frontend/AccountController.php:324 #: src/Frontend/AccountController.php:327
#, php-format #, php-format
msgid "Older versions (%d)" msgid "Older versions (%d)"
msgstr "Ältere Versionen (%d)" msgstr "Ältere Versionen (%d)"
#: src/Frontend/AccountController.php:424 #: src/Frontend/AccountController.php:427
#: src/Frontend/AccountController.php:491 #: src/Frontend/AccountController.php:494
msgid "License transferred successfully!" msgid "License transferred successfully!"
msgstr "Lizenz erfolgreich übertragen!" msgstr "Lizenz erfolgreich übertragen!"
#: src/Frontend/AccountController.php:425 #: src/Frontend/AccountController.php:428
msgid "Transfer failed. Please try again." msgid "Transfer failed. Please try again."
msgstr "Übertragung fehlgeschlagen. Bitte versuchen Sie es erneut." msgstr "Übertragung fehlgeschlagen. Bitte versuchen Sie es erneut."
#: src/Frontend/AccountController.php:426 #: src/Frontend/AccountController.php:429
msgid "" msgid ""
"Are you sure you want to transfer this license to a new domain? This action " "Are you sure you want to transfer this license to a new domain? This action "
"cannot be undone." "cannot be undone."
@@ -1598,31 +1651,31 @@ msgstr ""
"Sind Sie sicher, dass Sie diese Lizenz auf eine neue Domain übertragen " "Sind Sie sicher, dass Sie diese Lizenz auf eine neue Domain übertragen "
"möchten? Diese Aktion kann nicht rückgängig gemacht werden." "möchten? Diese Aktion kann nicht rückgängig gemacht werden."
#: src/Frontend/AccountController.php:445 #: src/Frontend/AccountController.php:448
msgid "Please log in to transfer a license." msgid "Please log in to transfer a license."
msgstr "Bitte melden Sie sich an, um eine Lizenz zu übertragen." msgstr "Bitte melden Sie sich an, um eine Lizenz zu übertragen."
#: src/Frontend/AccountController.php:451 #: src/Frontend/AccountController.php:454
msgid "Invalid license." msgid "Invalid license."
msgstr "Ungültige Lizenz." msgstr "Ungültige Lizenz."
#: src/Frontend/AccountController.php:469 #: src/Frontend/AccountController.php:472
msgid "You do not have permission to transfer this license." msgid "You do not have permission to transfer this license."
msgstr "Sie haben keine Berechtigung, diese Lizenz zu übertragen." msgstr "Sie haben keine Berechtigung, diese Lizenz zu übertragen."
#: src/Frontend/AccountController.php:474 #: src/Frontend/AccountController.php:477
msgid "Revoked licenses cannot be transferred." msgid "Revoked licenses cannot be transferred."
msgstr "Widerrufene Lizenzen können nicht übertragen werden." msgstr "Widerrufene Lizenzen können nicht übertragen werden."
#: src/Frontend/AccountController.php:478 #: src/Frontend/AccountController.php:481
msgid "Expired licenses cannot be transferred." msgid "Expired licenses cannot be transferred."
msgstr "Abgelaufene Lizenzen können nicht übertragen werden." msgstr "Abgelaufene Lizenzen können nicht übertragen werden."
#: src/Frontend/AccountController.php:483 #: src/Frontend/AccountController.php:486
msgid "The new domain is the same as the current domain." msgid "The new domain is the same as the current domain."
msgstr "Die neue Domain ist dieselbe wie die aktuelle Domain." msgstr "Die neue Domain ist dieselbe wie die aktuelle Domain."
#: src/Frontend/AccountController.php:495 #: src/Frontend/AccountController.php:498
msgid "Failed to transfer license. Please try again." msgid "Failed to transfer license. Please try again."
msgstr "Lizenzübertragung fehlgeschlagen. Bitte versuchen Sie es erneut." msgstr "Lizenzübertragung fehlgeschlagen. Bitte versuchen Sie es erneut."
@@ -1837,18 +1890,18 @@ msgstr ""
msgid "YOUR LICENSE KEYS" msgid "YOUR LICENSE KEYS"
msgstr "IHRE LIZENZSCHLÜSSEL" msgstr "IHRE LIZENZSCHLÜSSEL"
#: src/Plugin.php:318 #: src/Plugin.php:336
msgid "WC Licensed Product" msgid "WC Licensed Product"
msgstr "WC Licensed Product" msgstr "WC Licensed Product"
#: src/Plugin.php:319 #: src/Plugin.php:337
msgid "" msgid ""
"Plugin license is not configured or invalid. Frontend features are disabled." "Plugin license is not configured or invalid. Frontend features are disabled."
msgstr "" msgstr ""
"Plugin-Lizenz ist nicht konfiguriert oder ungültig. Frontend-Funktionen sind " "Plugin-Lizenz ist nicht konfiguriert oder ungültig. Frontend-Funktionen sind "
"deaktiviert." "deaktiviert."
#: src/Plugin.php:320 #: src/Plugin.php:338
msgid "Configure License" msgid "Configure License"
msgstr "Lizenz konfigurieren" msgstr "Lizenz konfigurieren"
@@ -1863,6 +1916,14 @@ msgstr ""
"WC Licensed Product benötigt WooCommerce als installierte und aktivierte " "WC Licensed Product benötigt WooCommerce als installierte und aktivierte "
"Erweiterung." "Erweiterung."
#~ msgid "API Verification Secret"
#~ msgstr "API-Verifizierungs-Secret"
#~ msgid "Use this secret to verify signed API responses. Keep it secure."
#~ msgstr ""
#~ "Verwenden Sie dieses Secret, um signierte API-Antworten zu verifizieren. "
#~ "Bewahren Sie es sicher auf."
#~ msgid "Domain for License Activation" #~ msgid "Domain for License Activation"
#~ msgstr "Domain für Lizenz-Aktivierung" #~ msgstr "Domain für Lizenz-Aktivierung"

View File

@@ -1,14 +1,14 @@
# SOME DESCRIPTIVE TITLE. # SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package. # This file is distributed under the same license as the WC Licensed Product package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: WC Licensed Product 0.5.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: magdev3.0@gmail.com\n"
"POT-Creation-Date: 2026-01-25 18:32+0100\n" "POT-Creation-Date: 2026-01-26 16:08+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -243,7 +243,7 @@ msgstr ""
#: src/Admin/AdminController.php:90 src/Admin/AdminController.php:1200 #: src/Admin/AdminController.php:90 src/Admin/AdminController.php:1200
#: src/Admin/OrderLicenseController.php:149 #: src/Admin/OrderLicenseController.php:149
#: src/Admin/OrderLicenseController.php:281 #: src/Admin/OrderLicenseController.php:281
#: src/Frontend/AccountController.php:90 #: src/Frontend/AccountController.php:91
msgid "Licenses" msgid "Licenses"
msgstr "" msgstr ""
@@ -288,7 +288,7 @@ msgstr ""
#: src/Admin/AdminController.php:149 src/Admin/AdminController.php:1341 #: src/Admin/AdminController.php:149 src/Admin/AdminController.php:1341
#: src/Admin/AdminController.php:1361 src/Admin/AdminController.php:1382 #: src/Admin/AdminController.php:1361 src/Admin/AdminController.php:1382
#: src/Admin/AdminController.php:1537 src/Admin/OrderLicenseController.php:244 #: src/Admin/AdminController.php:1537 src/Admin/OrderLicenseController.php:244
#: src/Frontend/AccountController.php:384 #: src/Frontend/AccountController.php:387
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
@@ -303,17 +303,19 @@ msgstr ""
#: src/Admin/AdminController.php:1373 src/Admin/AdminController.php:1613 #: src/Admin/AdminController.php:1373 src/Admin/AdminController.php:1613
#: src/Admin/DashboardWidgetController.php:136 #: src/Admin/DashboardWidgetController.php:136
#: src/Admin/OrderLicenseController.php:260 #: src/Admin/OrderLicenseController.php:260
#: src/Admin/SettingsController.php:192 src/Product/LicensedProductType.php:110 #: src/Admin/SettingsController.php:192 src/Product/LicensedProductType.php:136
#: src/Product/LicensedProductType.php:158 #: src/Product/LicensedProductType.php:184
#: src/Frontend/AccountController.php:283 #: src/Product/LicensedProductType.php:379
#: src/Product/LicensedProductVariation.php:139
#: src/Frontend/AccountController.php:286
msgid "Lifetime" msgid "Lifetime"
msgstr "" msgstr ""
#: src/Admin/AdminController.php:152 src/Frontend/AccountController.php:422 #: src/Admin/AdminController.php:152 src/Frontend/AccountController.php:425
msgid "Copied!" msgid "Copied!"
msgstr "" msgstr ""
#: src/Admin/AdminController.php:153 src/Frontend/AccountController.php:423 #: src/Admin/AdminController.php:153 src/Frontend/AccountController.php:426
msgid "Copy failed" msgid "Copy failed"
msgstr "" msgstr ""
@@ -401,7 +403,7 @@ msgstr ""
#: src/Admin/AdminController.php:531 src/Admin/AdminController.php:549 #: src/Admin/AdminController.php:531 src/Admin/AdminController.php:549
#: src/Admin/AdminController.php:577 src/Admin/AdminController.php:621 #: src/Admin/AdminController.php:577 src/Admin/AdminController.php:621
#: src/Admin/AdminController.php:811 src/Admin/SettingsController.php:469 #: src/Admin/AdminController.php:811 src/Admin/SettingsController.php:469
#: src/Frontend/AccountController.php:439 #: src/Frontend/AccountController.php:442
msgid "Security check failed." msgid "Security check failed."
msgstr "" msgstr ""
@@ -668,8 +670,8 @@ msgstr ""
#: src/Admin/AdminController.php:1294 src/Admin/AdminController.php:1445 #: src/Admin/AdminController.php:1294 src/Admin/AdminController.php:1445
#: src/Admin/AdminController.php:1495 src/Admin/OrderLicenseController.php:205 #: src/Admin/AdminController.php:1495 src/Admin/OrderLicenseController.php:205
#: src/Checkout/CheckoutBlocksIntegration.php:129 #: src/Checkout/CheckoutBlocksIntegration.php:130
#: src/Checkout/CheckoutController.php:122 #: src/Checkout/CheckoutController.php:161
#: src/Email/LicenseEmailController.php:288 #: src/Email/LicenseEmailController.php:288
msgid "Domain" msgid "Domain"
msgstr "" msgstr ""
@@ -689,7 +691,7 @@ msgstr ""
msgid "No licenses found." msgid "No licenses found."
msgstr "" msgstr ""
#: src/Admin/AdminController.php:1314 src/Frontend/AccountController.php:260 #: src/Admin/AdminController.php:1314 src/Frontend/AccountController.php:263
msgid "Copy to clipboard" msgid "Copy to clipboard"
msgstr "" msgstr ""
@@ -709,7 +711,7 @@ msgstr ""
msgid "Test" msgid "Test"
msgstr "" msgstr ""
#: src/Admin/AdminController.php:1400 src/Frontend/AccountController.php:267 #: src/Admin/AdminController.php:1400 src/Frontend/AccountController.php:270
msgid "Transfer to new domain" msgid "Transfer to new domain"
msgstr "" msgstr ""
@@ -737,27 +739,27 @@ msgstr ""
msgid "Testing license..." msgid "Testing license..."
msgstr "" msgstr ""
#: src/Admin/AdminController.php:1508 src/Frontend/AccountController.php:362 #: src/Admin/AdminController.php:1508 src/Frontend/AccountController.php:365
msgid "Close" msgid "Close"
msgstr "" msgstr ""
#: src/Admin/AdminController.php:1517 src/Frontend/AccountController.php:363 #: src/Admin/AdminController.php:1517 src/Frontend/AccountController.php:366
msgid "Transfer License to New Domain" msgid "Transfer License to New Domain"
msgstr "" msgstr ""
#: src/Admin/AdminController.php:1524 src/Frontend/AccountController.php:368 #: src/Admin/AdminController.php:1524 src/Frontend/AccountController.php:371
msgid "Current Domain" msgid "Current Domain"
msgstr "" msgstr ""
#: src/Admin/AdminController.php:1528 src/Frontend/AccountController.php:373 #: src/Admin/AdminController.php:1528 src/Frontend/AccountController.php:376
msgid "New Domain" msgid "New Domain"
msgstr "" msgstr ""
#: src/Admin/AdminController.php:1531 src/Frontend/AccountController.php:377 #: src/Admin/AdminController.php:1531 src/Frontend/AccountController.php:380
msgid "Enter the new domain without http:// or www." msgid "Enter the new domain without http:// or www."
msgstr "" msgstr ""
#: src/Admin/AdminController.php:1536 src/Frontend/AccountController.php:382 #: src/Admin/AdminController.php:1536 src/Frontend/AccountController.php:385
msgid "Transfer License" msgid "Transfer License"
msgstr "" msgstr ""
@@ -940,11 +942,11 @@ msgid "Domains specified during checkout (multi-domain order)."
msgstr "" msgstr ""
#: src/Admin/OrderLicenseController.php:119 #: src/Admin/OrderLicenseController.php:119
#: src/Checkout/CheckoutController.php:436 #: src/Checkout/CheckoutController.php:530
#: src/Checkout/CheckoutController.php:486 #: src/Checkout/CheckoutController.php:591
#: src/Checkout/CheckoutController.php:496 src/License/LicenseManager.php:806 #: src/Checkout/CheckoutController.php:613 src/License/LicenseManager.php:878
#: src/Product/VersionManager.php:349 src/Product/VersionManager.php:361 #: src/Product/VersionManager.php:349 src/Product/VersionManager.php:361
#: src/Frontend/AccountController.php:146 #: src/Frontend/AccountController.php:148
#: src/Email/LicenseExpirationEmail.php:107 #: src/Email/LicenseExpirationEmail.php:107
#: src/Email/LicenseExpiredEmail.php:99 #: src/Email/LicenseExpiredEmail.php:99
msgid "Unknown Product" msgid "Unknown Product"
@@ -957,10 +959,10 @@ msgid ""
msgstr "" msgstr ""
#: src/Admin/OrderLicenseController.php:137 #: src/Admin/OrderLicenseController.php:137
#: src/Checkout/CheckoutBlocksIntegration.php:83 #: src/Checkout/CheckoutBlocksIntegration.php:84
#: src/Checkout/CheckoutBlocksIntegration.php:119 #: src/Checkout/CheckoutBlocksIntegration.php:120
#: src/Checkout/CheckoutController.php:130 #: src/Checkout/CheckoutController.php:169
#: src/Checkout/CheckoutController.php:186 #: src/Checkout/CheckoutController.php:235
msgid "example.com" msgid "example.com"
msgstr "" msgstr ""
@@ -1019,9 +1021,9 @@ msgid "Error. Please try again."
msgstr "" msgstr ""
#: src/Admin/OrderLicenseController.php:373 #: src/Admin/OrderLicenseController.php:373
#: src/Checkout/CheckoutBlocksIntegration.php:126 #: src/Checkout/CheckoutBlocksIntegration.php:127
#: src/Frontend/AccountController.php:427 #: src/Frontend/AccountController.php:430
#: src/Frontend/AccountController.php:459 #: src/Frontend/AccountController.php:462
msgid "Please enter a valid domain." msgid "Please enter a valid domain."
msgstr "" msgstr ""
@@ -1045,7 +1047,7 @@ msgstr ""
#: src/Admin/OrderLicenseController.php:449 #: src/Admin/OrderLicenseController.php:449
#: src/Frontend/DownloadController.php:117 #: src/Frontend/DownloadController.php:117
#: src/Frontend/AccountController.php:465 #: src/Frontend/AccountController.php:468
msgid "License not found." msgid "License not found."
msgstr "" msgstr ""
@@ -1241,7 +1243,7 @@ msgid "Too many requests. Please try again later."
msgstr "" msgstr ""
#: src/Api/RestApiController.php:345 src/Api/RestApiController.php:378 #: src/Api/RestApiController.php:345 src/Api/RestApiController.php:378
#: src/License/LicenseManager.php:403 #: src/License/LicenseManager.php:475
msgid "License key not found." msgid "License key not found."
msgstr "" msgstr ""
@@ -1265,89 +1267,95 @@ msgstr ""
msgid "License activated successfully." msgid "License activated successfully."
msgstr "" msgstr ""
#: src/Checkout/CheckoutBlocksIntegration.php:78 #: src/Checkout/CheckoutBlocksIntegration.php:79
#: src/Checkout/CheckoutBlocksIntegration.php:125 #: src/Checkout/CheckoutBlocksIntegration.php:126
#: src/Checkout/CheckoutController.php:119 #: src/Checkout/CheckoutController.php:158
msgid "License Domain" msgid "License Domain"
msgstr "" msgstr ""
#: src/Checkout/CheckoutBlocksIntegration.php:85 #: src/Checkout/CheckoutBlocksIntegration.php:86
msgid "Enter a valid domain (without http:// or www)" msgid "Enter a valid domain (without http:// or www)"
msgstr "" msgstr ""
#: src/Checkout/CheckoutBlocksIntegration.php:121 #: src/Checkout/CheckoutBlocksIntegration.php:122
#: src/Checkout/CheckoutController.php:150 #: src/Checkout/CheckoutController.php:189
msgid "Enter a unique domain for each license (without http:// or www)." msgid "Enter a unique domain for each license (without http:// or www)."
msgstr "" msgstr ""
#: src/Checkout/CheckoutBlocksIntegration.php:122 #: src/Checkout/CheckoutBlocksIntegration.php:123
#: src/Checkout/CheckoutController.php:134 #: src/Checkout/CheckoutController.php:173
msgid "" msgid ""
"Enter the domain where you will use the license (without http:// or www)." "Enter the domain where you will use the license (without http:// or www)."
msgstr "" msgstr ""
#: src/Checkout/CheckoutBlocksIntegration.php:124 #: src/Checkout/CheckoutBlocksIntegration.php:125
#: src/Checkout/CheckoutController.php:148 #: src/Checkout/CheckoutController.php:187
msgid "License Domains" msgid "License Domains"
msgstr "" msgstr ""
#: src/Checkout/CheckoutBlocksIntegration.php:127 #: src/Checkout/CheckoutBlocksIntegration.php:128
msgid "Each license requires a unique domain." msgid "Each license requires a unique domain."
msgstr "" msgstr ""
#: src/Checkout/CheckoutBlocksIntegration.php:128 #: src/Checkout/CheckoutBlocksIntegration.php:129
#: src/Checkout/CheckoutController.php:175 #: src/Checkout/CheckoutController.php:224
#, php-format #, php-format
msgid "License %d:" msgid "License %d:"
msgstr "" msgstr ""
#: src/Checkout/CheckoutController.php:123 #: src/Checkout/CheckoutController.php:162
#: src/Checkout/CheckoutController.php:179 #: src/Checkout/CheckoutController.php:228
msgid "required" msgid "required"
msgstr "" msgstr ""
#: src/Checkout/CheckoutController.php:258 #: src/Checkout/CheckoutController.php:323
msgid "Please enter a domain for your license." msgid "Please enter a domain for your license."
msgstr "" msgstr ""
#: src/Checkout/CheckoutController.php:264 #: src/Checkout/CheckoutController.php:329
msgid "Please enter a valid domain for your license." msgid "Please enter a valid domain for your license."
msgstr "" msgstr ""
#: src/Checkout/CheckoutController.php:287 #: src/Checkout/CheckoutController.php:356
#, php-format #, php-format
msgid "Please enter a domain for %1$s (License %2$d)." msgid "Please enter a domain for %1$s (License %2$d)."
msgstr "" msgstr ""
#: src/Checkout/CheckoutController.php:302 #: src/Checkout/CheckoutController.php:371
#, php-format #, php-format
msgid "Please enter a valid domain for %1$s (License %2$d)." msgid "Please enter a valid domain for %1$s (License %2$d)."
msgstr "" msgstr ""
#: src/Checkout/CheckoutController.php:316 #: src/Checkout/CheckoutController.php:385
#, php-format #, php-format
msgid "" msgid ""
"The domain \"%1$s\" is used multiple times for %2$s. Each license requires a " "The domain \"%1$s\" is used multiple times for %2$s. Each license requires a "
"unique domain." "unique domain."
msgstr "" msgstr ""
#: src/Checkout/CheckoutController.php:419 #: src/Checkout/CheckoutController.php:500
#: src/Checkout/CheckoutController.php:466 #: src/Checkout/CheckoutController.php:561
#: src/Checkout/CheckoutController.php:470 #: src/Checkout/CheckoutController.php:565
msgid "License Domain:" msgid "License Domain:"
msgstr "" msgstr ""
#: src/Checkout/CheckoutController.php:432 #: src/Checkout/CheckoutController.php:513
#: src/Checkout/CheckoutController.php:483 #: src/Checkout/CheckoutController.php:578
#: src/Checkout/CheckoutController.php:492 #: src/Checkout/CheckoutController.php:599
msgid "License Domains:" msgid "License Domains:"
msgstr "" msgstr ""
#: src/Checkout/CheckoutController.php:522
#: src/Checkout/CheckoutController.php:585
#: src/Checkout/CheckoutController.php:607
msgid "Unknown Variation"
msgstr ""
#: src/Checkout/StoreApiExtension.php:93 #: src/Checkout/StoreApiExtension.php:93
msgid "Domains for license activation by product" msgid "Domains for license activation by product"
msgstr "" msgstr ""
#: src/Checkout/StoreApiExtension.php:117 #: src/Checkout/StoreApiExtension.php:120
msgid "Domain for license activation" msgid "Domain for license activation"
msgstr "" msgstr ""
@@ -1359,85 +1367,111 @@ msgstr ""
msgid "Could not connect to license server." msgid "Could not connect to license server."
msgstr "" msgstr ""
#: src/License/LicenseManager.php:412 #: src/License/LicenseManager.php:484
msgid "This license has been revoked." msgid "This license has been revoked."
msgstr "" msgstr ""
#: src/License/LicenseManager.php:422 #: src/License/LicenseManager.php:494
msgid "This license has expired." msgid "This license has expired."
msgstr "" msgstr ""
#: src/License/LicenseManager.php:430 #: src/License/LicenseManager.php:502
msgid "This license is inactive." msgid "This license is inactive."
msgstr "" msgstr ""
#: src/License/LicenseManager.php:440 #: src/License/LicenseManager.php:512
msgid "This license is not valid for this domain." msgid "This license is not valid for this domain."
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:61 #: src/Product/LicensedProductType.php:72
msgid "Licensed Product" msgid "Licensed Product"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:82 #: src/Product/LicensedProductType.php:73
msgid "Licensed Variable Product"
msgstr ""
#: src/Product/LicensedProductType.php:108
msgid "License Settings" msgid "License Settings"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:109 #: src/Product/LicensedProductType.php:135
#: src/Product/LicensedProductType.php:378
#, php-format #, php-format
msgid "%d days" msgid "%d days"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:119 #: src/Product/LicensedProductType.php:145
#, php-format #, php-format
msgid "Leave fields empty to use default settings from %s." msgid "Leave fields empty to use default settings from %s."
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:121 #: src/Product/LicensedProductType.php:147
msgid "WooCommerce > Settings > Licensed Products" msgid "WooCommerce > Settings > Licensed Products"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:128 #: src/Product/LicensedProductType.php:154
#: src/Product/LicensedProductType.php:396
msgid "Max Activations" msgid "Max Activations"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:131 #: src/Product/LicensedProductType.php:157
#, php-format #, php-format
msgid "Maximum number of domain activations per license. Default: %d" msgid "Maximum number of domain activations per license. Default: %d"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:146 #: src/Product/LicensedProductType.php:172
msgid "License Validity (Days)" msgid "License Validity (Days)"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:149 #: src/Product/LicensedProductType.php:175
#, php-format #, php-format
msgid "Number of days the license is valid. Leave empty for default (%s)." msgid "Number of days the license is valid. Leave empty for default (%s)."
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:164 #: src/Product/LicensedProductType.php:190
msgid "Bind to Major Version" msgid "Bind to Major Version"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:167 #: src/Product/LicensedProductType.php:193
#, php-format #, php-format
msgid "" msgid ""
"If enabled, licenses are bound to the major version at purchase time. " "If enabled, licenses are bound to the major version at purchase time. "
"Default: %s" "Default: %s"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:168 #: src/Product/LicensedProductType.php:194
msgid "Yes" msgid "Yes"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:168 #: src/Product/LicensedProductType.php:194
msgid "No" msgid "No"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:288 #: src/Product/LicensedProductType.php:321
msgid "Version:" msgid "Version:"
msgstr "" msgstr ""
#: src/Product/LicensedProductType.php:349
msgid "Licensed products are always virtual"
msgstr ""
#: src/Product/LicensedProductType.php:351
msgid "Virtual"
msgstr ""
#: src/Product/LicensedProductType.php:384
msgid "License Duration (Days)"
msgstr ""
#: src/Product/LicensedProductType.php:393
msgid "Leave empty for parent default. 0 = Lifetime."
msgstr ""
#: src/Product/LicensedProductType.php:405
msgid "Leave empty for parent default."
msgstr ""
#: src/Product/VersionManager.php:166 #: src/Product/VersionManager.php:166
msgid "Attachment file not found." msgid "Attachment file not found."
msgstr "" msgstr ""
@@ -1447,6 +1481,25 @@ msgstr ""
msgid "File checksum does not match. Expected: %1$s, Got: %2$s" msgid "File checksum does not match. Expected: %1$s, Got: %2$s"
msgstr "" msgstr ""
#: src/Product/LicensedProductVariation.php:143
msgid "Monthly"
msgstr ""
#: src/Product/LicensedProductVariation.php:147
msgid "Quarterly"
msgstr ""
#: src/Product/LicensedProductVariation.php:151
msgid "Yearly"
msgstr ""
#: src/Product/LicensedProductVariation.php:156
#, php-format
msgid "%d day"
msgid_plural "%d days"
msgstr[0] ""
msgstr[1] ""
#: src/Frontend/DownloadController.php:77 #: src/Frontend/DownloadController.php:77
#: src/Frontend/DownloadController.php:101 #: src/Frontend/DownloadController.php:101
msgid "Invalid download link." msgid "Invalid download link."
@@ -1498,78 +1551,78 @@ msgstr ""
msgid "Download file not found." msgid "Download file not found."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:104 #: src/Frontend/AccountController.php:105
msgid "Please log in to view your licenses." msgid "Please log in to view your licenses."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:220 #: src/Frontend/AccountController.php:223
msgid "You have no licenses yet." msgid "You have no licenses yet."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:242 #: src/Frontend/AccountController.php:245
#, php-format #, php-format
msgid "Order #%s" msgid "Order #%s"
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:293 #: src/Frontend/AccountController.php:296
msgid "Available Downloads" msgid "Available Downloads"
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:302 #: src/Frontend/AccountController.php:305
#: src/Frontend/AccountController.php:335 #: src/Frontend/AccountController.php:338
#, php-format #, php-format
msgid "Version %s" msgid "Version %s"
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:304 #: src/Frontend/AccountController.php:307
msgid "Latest" msgid "Latest"
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:324 #: src/Frontend/AccountController.php:327
#, php-format #, php-format
msgid "Older versions (%d)" msgid "Older versions (%d)"
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:424 #: src/Frontend/AccountController.php:427
#: src/Frontend/AccountController.php:491 #: src/Frontend/AccountController.php:494
msgid "License transferred successfully!" msgid "License transferred successfully!"
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:425 #: src/Frontend/AccountController.php:428
msgid "Transfer failed. Please try again." msgid "Transfer failed. Please try again."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:426 #: src/Frontend/AccountController.php:429
msgid "" msgid ""
"Are you sure you want to transfer this license to a new domain? This action " "Are you sure you want to transfer this license to a new domain? This action "
"cannot be undone." "cannot be undone."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:445 #: src/Frontend/AccountController.php:448
msgid "Please log in to transfer a license." msgid "Please log in to transfer a license."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:451 #: src/Frontend/AccountController.php:454
msgid "Invalid license." msgid "Invalid license."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:469 #: src/Frontend/AccountController.php:472
msgid "You do not have permission to transfer this license." msgid "You do not have permission to transfer this license."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:474 #: src/Frontend/AccountController.php:477
msgid "Revoked licenses cannot be transferred." msgid "Revoked licenses cannot be transferred."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:478 #: src/Frontend/AccountController.php:481
msgid "Expired licenses cannot be transferred." msgid "Expired licenses cannot be transferred."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:483 #: src/Frontend/AccountController.php:486
msgid "The new domain is the same as the current domain." msgid "The new domain is the same as the current domain."
msgstr "" msgstr ""
#: src/Frontend/AccountController.php:495 #: src/Frontend/AccountController.php:498
msgid "Failed to transfer license. Please try again." msgid "Failed to transfer license. Please try again."
msgstr "" msgstr ""
@@ -1772,16 +1825,16 @@ msgstr ""
msgid "YOUR LICENSE KEYS" msgid "YOUR LICENSE KEYS"
msgstr "" msgstr ""
#: src/Plugin.php:318 #: src/Plugin.php:336
msgid "WC Licensed Product" msgid "WC Licensed Product"
msgstr "" msgstr ""
#: src/Plugin.php:319 #: src/Plugin.php:337
msgid "" msgid ""
"Plugin license is not configured or invalid. Frontend features are disabled." "Plugin license is not configured or invalid. Frontend features are disabled."
msgstr "" msgstr ""
#: src/Plugin.php:320 #: src/Plugin.php:338
msgid "Configure License" msgid "Configure License"
msgstr "" msgstr ""

View File

@@ -147,9 +147,52 @@ final class ResponseSigner
*/ */
private function deriveKey(string $licenseKey): string private function deriveKey(string $licenseKey): string
{ {
// HKDF-like key derivation return self::deriveCustomerSecret($licenseKey, $this->serverSecret);
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true); }
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret); /**
* Derive a customer-specific secret from a license key
*
* This secret is unique per license and can be shared with the customer
* to verify signed API responses. Each customer gets their own secret
* derived from their license key.
*
* @param string $licenseKey The customer's license key
* @param string $serverSecret The server's master secret
* @return string The derived secret (64 hex characters)
*/
public static function deriveCustomerSecret(string $licenseKey, string $serverSecret): string
{
// HKDF-like key derivation
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
}
/**
* Get the customer secret for a license key using the configured server secret
*
* @param string $licenseKey The customer's license key
* @return string|null The derived secret, or null if server secret is not configured
*/
public static function getCustomerSecretForLicense(string $licenseKey): ?string
{
$serverSecret = defined('WC_LICENSE_SERVER_SECRET') ? WC_LICENSE_SERVER_SECRET : '';
if (empty($serverSecret)) {
return null;
}
return self::deriveCustomerSecret($licenseKey, $serverSecret);
}
/**
* Check if response signing is enabled
*
* @return bool True if server secret is configured
*/
public static function isSigningEnabled(): bool
{
return defined('WC_LICENSE_SERVER_SECRET') && !empty(WC_LICENSE_SERVER_SECRET);
} }
} }

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Frontend; namespace Jeremias\WcLicensedProduct\Frontend;
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\VersionManager; use Jeremias\WcLicensedProduct\Product\VersionManager;
use Twig\Environment; use Twig\Environment;
@@ -114,6 +115,7 @@ final class AccountController
echo $this->twig->render('frontend/licenses.html.twig', [ echo $this->twig->render('frontend/licenses.html.twig', [
'packages' => $packages, 'packages' => $packages,
'has_packages' => !empty($packages), 'has_packages' => !empty($packages),
'signing_enabled' => ResponseSigner::isSigningEnabled(),
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
// Fallback to PHP template if Twig fails // Fallback to PHP template if Twig fails
@@ -161,6 +163,7 @@ final class AccountController
'status' => $license->getStatus(), 'status' => $license->getStatus(),
'expires_at' => $license->getExpiresAt(), 'expires_at' => $license->getExpiresAt(),
'is_transferable' => in_array($license->getStatus(), ['active', 'inactive'], true), 'is_transferable' => in_array($license->getStatus(), ['active', 'inactive'], true),
'customer_secret' => ResponseSigner::getCustomerSecretForLicense($license->getLicenseKey()),
]; ];
// Track if package has at least one active license // Track if package has at least one active license

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,6 +65,26 @@
{% endif %} {% endif %}
</span> </span>
</div> </div>
{% if signing_enabled and license.customer_secret %}
<div class="license-row-secret">
<button type="button" class="secret-toggle" aria-expanded="false">
<span class="dashicons dashicons-lock"></span>
{{ __('API Verification Secret') }}
<span class="dashicons dashicons-arrow-down-alt2 toggle-arrow"></span>
</button>
<div class="secret-content" style="display: none;">
<p class="secret-description">
{{ __('Use this secret to verify signed API responses. Keep it secure.') }}
</p>
<div class="secret-value-wrapper">
<code class="secret-value">{{ esc_html(license.customer_secret) }}</code>
<button type="button" class="copy-secret-btn" data-secret="{{ esc_attr(license.customer_secret) }}" title="{{ __('Copy to clipboard') }}">
<span class="dashicons dashicons-clipboard"></span>
</button>
</div>
</div>
</div>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce Licensed Product * Plugin Name: WooCommerce Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation. * Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
* Version: 0.5.1 * Version: 0.5.3
* 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.1'); define('WC_LICENSED_PRODUCT_VERSION', '0.5.3');
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__));