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>
This commit is contained in:
2026-01-26 16:14:15 +01:00
parent 8cac742f57
commit c31df1e8c4
15 changed files with 2235 additions and 1184 deletions

View File

@@ -11,6 +11,7 @@ namespace Jeremias\WcLicensedProduct\Checkout;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
/**
* Integration with WooCommerce Checkout Blocks
@@ -141,7 +142,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
/**
* 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
{
@@ -152,13 +153,49 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
$licensedProducts = [];
foreach (WC()->cart->get_cart() as $cartItem) {
$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();
$licensedProducts[] = [
'product_id' => $productId,
'variation_id' => 0,
'name' => $product->get_name(),
'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\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
/**
* Handles checkout modifications for licensed products
@@ -57,7 +58,7 @@ final class CheckoutController
/**
* 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
{
@@ -68,13 +69,51 @@ final class CheckoutController
$licensedProducts = [];
foreach (WC()->cart->get_cart() as $cartItem) {
$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();
$licensedProducts[$productId] = [
'product_id' => $productId,
'variation_id' => 0,
'name' => $product->get_name(),
'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'); ?>
</p>
<?php foreach ($licensedProducts as $productId => $productData): ?>
<div class="wclp-product-domains" data-product-id="<?php echo esc_attr($productId); ?>">
<?php foreach ($licensedProducts as $key => $productData): ?>
<?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>
<?php
echo esc_html($productData['name']);
if (!empty($durationLabel)) {
echo ' <span class="wclp-duration-badge">(' . esc_html($durationLabel) . ')</span>';
}
if ($productData['quantity'] > 1) {
printf(' (×%d)', $productData['quantity']);
printf(' ×%d', $productData['quantity']);
}
?>
</h4>
<?php for ($i = 0; $i < $productData['quantity']; $i++): ?>
<?php
$fieldName = sprintf('licensed_domains[%d][%d]', $productId, $i);
$fieldId = sprintf('licensed_domain_%d_%d', $productId, $i);
$savedValue = $this->getSavedDomainValue($productId, $i);
$fieldName = sprintf('licensed_domains[%s][%d]', $fieldKey, $i);
$fieldId = sprintf('licensed_domain_%s_%d', str_replace('_', '-', $fieldKey), $i);
$savedValue = $this->getSavedDomainValue($productId, $i, $variationId);
?>
<p class="form-row form-row-wide wclp-domain-row">
<label for="<?php echo esc_attr($fieldId); ?>">
@@ -186,6 +235,9 @@ final class CheckoutController
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>"
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>
<?php endfor; ?>
</div>
@@ -197,6 +249,7 @@ final class CheckoutController
.wclp-domain-description { margin-bottom: 15px; color: #666; }
.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-duration-badge { color: #0073aa; font-weight: normal; }
.wclp-domain-row { margin-bottom: 10px; }
.wclp-domain-row:last-child { margin-bottom: 0; }
.wclp-domain-row label { display: block; margin-bottom: 5px; }
@@ -207,9 +260,17 @@ final class CheckoutController
/**
* 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)
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])) {
return sanitize_text_field($_POST['licensed_domains'][$productId][$index]);
}
@@ -218,7 +279,11 @@ final class CheckoutController
if (WC()->session) {
$sessionDomains = WC()->session->get('licensed_product_domains', []);
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])) {
return $item['domains'][$index];
}
@@ -272,8 +337,12 @@ final class CheckoutController
{
$licensedDomains = $_POST['licensed_domains'] ?? [];
foreach ($licensedProducts as $productId => $productData) {
$productDomains = $licensedDomains[$productId] ?? [];
foreach ($licensedProducts as $key => $productData) {
$productId = $productData['product_id'];
$variationId = $productData['variation_id'] ?? 0;
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
$productDomains = $licensedDomains[$fieldKey] ?? $licensedDomains[$productId] ?? [];
$normalizedDomains = [];
for ($i = 0; $i < $productData['quantity']; $i++) {
@@ -308,7 +377,7 @@ final class CheckoutController
continue;
}
// Check for duplicate domains within same product
// Check for duplicate domains within same product/variation
if (in_array($normalizedDomain, $normalizedDomains, true)) {
wc_add_notice(
sprintf(
@@ -369,10 +438,15 @@ final class CheckoutController
private function saveMultiDomainFields(\WC_Order $order, array $licensedProducts): void
{
$licensedDomains = $_POST['licensed_domains'] ?? [];
$licensedVariationIds = $_POST['licensed_variation_ids'] ?? [];
$domainData = [];
foreach ($licensedProducts as $productId => $productData) {
$productDomains = $licensedDomains[$productId] ?? [];
foreach ($licensedProducts as $key => $productData) {
$productId = $productData['product_id'];
$variationId = $productData['variation_id'] ?? 0;
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
$productDomains = $licensedDomains[$fieldKey] ?? $licensedDomains[$productId] ?? [];
$normalizedDomains = [];
for ($i = 0; $i < $productData['quantity']; $i++) {
@@ -383,10 +457,17 @@ final class CheckoutController
}
if (!empty($normalizedDomains)) {
$domainData[] = [
$entry = [
'product_id' => $productId,
'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>
<?php foreach ($domainData as $item): ?>
<?php
$product = wc_get_product($item['product_id']);
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
$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');
}
?>
<p style="margin: 5px 0 5px 15px;">
<em><?php echo esc_html($productName); ?>:</em><br>
@@ -482,8 +577,20 @@ final class CheckoutController
if ($plainText) {
echo "\n" . esc_html__('License Domains:', 'wc-licensed-product') . "\n";
foreach ($domainData as $item) {
$product = wc_get_product($item['product_id']);
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
$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');
}
echo ' ' . esc_html($productName) . ': ' . esc_html(implode(', ', $item['domains'])) . "\n";
}
} else {
@@ -492,8 +599,19 @@ final class CheckoutController
<strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong>
<?php foreach ($domainData as $item): ?>
<?php
$product = wc_get_product($item['product_id']);
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
$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');
}
?>
<p style="margin: 5px 0 5px 15px;">
<em><?php echo esc_html($productName); ?>:</em><br>

View File

@@ -100,6 +100,9 @@ final class StoreApiExtension
'product_id' => [
'type' => 'integer',
],
'variation_id' => [
'type' => 'integer',
],
'domains' => [
'type' => 'array',
'items' => [
@@ -162,6 +165,7 @@ final class StoreApiExtension
}
$productId = (int) $item['product_id'];
$variationId = isset($item['variation_id']) ? (int) $item['variation_id'] : 0;
$domains = [];
foreach ($item['domains'] as $domain) {
@@ -172,10 +176,17 @@ final class StoreApiExtension
}
if (!empty($domains)) {
$normalized[] = [
$entry = [
'product_id' => $productId,
'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)
if (empty($domainData) && isset($requestData['licensed_domains']) && is_array($requestData['licensed_domains'])) {
$domainData = [];
foreach ($requestData['licensed_domains'] as $productId => $domains) {
$variationIds = $requestData['licensed_variation_ids'] ?? [];
foreach ($requestData['licensed_domains'] as $key => $domains) {
if (!is_array($domains)) {
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 = [];
foreach ($domains as $domain) {
$sanitized = sanitize_text_field($domain);
@@ -279,10 +303,16 @@ final class StoreApiExtension
}
}
if (!empty($normalizedDomains)) {
$domainData[] = [
'product_id' => (int) $productId,
$entry = [
'product_id' => $productId,
'domains' => $normalizedDomains,
];
if ($variationId > 0) {
$entry['variation_id'] = $variationId;
}
$domainData[] = $entry;
}
}
}

View File

@@ -11,12 +11,43 @@ namespace Jeremias\WcLicensedProduct\License;
use Jeremias\WcLicensedProduct\Installer;
use Jeremias\WcLicensedProduct\Product\LicensedProduct;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
use Jeremias\WcLicensedProduct\Product\LicensedVariableProduct;
/**
* Manages license operations (CRUD, validation, generation)
*/
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
*/
@@ -40,32 +71,63 @@ class LicenseManager
/**
* 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(
int $orderId,
int $productId,
int $customerId,
string $domain
string $domain,
?int $variationId = null
): ?License {
global $wpdb;
// Normalize domain first for duplicate detection
$normalizedDomain = $this->normalizeDomain($domain);
// Check if license already exists for this order, product, and domain
$existing = $this->getLicenseByOrderProductAndDomain($orderId, $productId, $normalizedDomain);
// Check if license already exists for this order, product, domain, and variation
$existing = $this->getLicenseByOrderProductDomainAndVariation($orderId, $productId, $normalizedDomain, $variationId);
if ($existing) {
return $existing;
}
$product = wc_get_product($productId);
if (!$product || !$product->is_type('licensed')) {
return null;
// Load the product that has the license settings
// 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;
}
// Ensure we have the LicensedProduct instance for type hints
if (!$settingsProduct instanceof LicensedProduct) {
$settingsProduct = new LicensedProduct($productId);
}
}
// Ensure we have the LicensedProduct instance for type hints
if (!$product instanceof LicensedProduct) {
$product = new LicensedProduct($productId);
if (!$settingsProduct) {
return null;
}
// Generate unique license key
@@ -74,16 +136,16 @@ class LicenseManager
$licenseKey = $this->generateLicenseKey();
}
// Calculate expiration date
// Calculate expiration date from the settings product (variation or parent)
$expiresAt = null;
$validityDays = $product->get_validity_days();
$validityDays = $settingsProduct->get_validity_days();
if ($validityDays !== null && $validityDays > 0) {
$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;
if ($product->is_bound_to_version()) {
if ($settingsProduct->is_bound_to_version()) {
$versionId = $this->getCurrentVersionId($productId);
}
@@ -99,7 +161,7 @@ class LicenseManager
'version_id' => $versionId,
'status' => License::STATUS_ACTIVE,
'activations_count' => 1,
'max_activations' => $product->get_max_activations(),
'max_activations' => $settingsProduct->get_max_activations(),
'expires_at' => $expiresAt,
],
['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s']
@@ -112,6 +174,16 @@ class LicenseManager
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
*/

View File

@@ -227,23 +227,35 @@ final class Plugin
$orderId = $order->get_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 = [];
foreach ($domainData as $item) {
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
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if (!$product || !$product->is_type('licensed')) {
if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
continue;
}
$productId = $product->get_id();
$domains = $domainsByProduct[$productId] ?? [];
// 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();
// 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
foreach ($domains as $domain) {
@@ -252,7 +264,8 @@ final class Plugin
$orderId,
$productId,
$customerId,
$domain
$domain,
$variationId > 0 ? $variationId : null
);
}
}
@@ -271,12 +284,17 @@ final class Plugin
foreach ($order->get_items() as $item) {
$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(
$order->get_id(),
$product->get_id(),
$productId,
$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;
/**
* 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
{
@@ -29,7 +30,7 @@ final class LicensedProductType
*/
private function registerHooks(): void
{
// Register product type
// Register product types
add_filter('product_type_selector', [$this, 'addProductType']);
add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 2);
@@ -39,9 +40,11 @@ final class LicensedProductType
// Save product meta
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
add_action('woocommerce_licensed_add_to_cart', [$this, 'addToCartTemplate']);
add_action('woocommerce_licensed-variable_add_to_cart', [$this, 'variableAddToCartTemplate']);
// Make product virtual by default
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
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
{
$types['licensed'] = __('Licensed Product', 'wc-licensed-product');
$types['licensed-variable'] = __('Licensed Variable Product', 'wc-licensed-product');
return $types;
}
/**
* Get product class for licensed type
* Get product class for licensed types
*/
public function getProductClass(string $className, string $productType): string
{
if ($productType === 'licensed') {
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;
}
@@ -81,7 +107,7 @@ final class LicensedProductType
$tabs['licensed_product'] = [
'label' => __('License Settings', 'wc-licensed-product'),
'target' => 'licensed_product_data',
'class' => ['show_if_licensed'],
'class' => ['show_if_licensed', 'show_if_licensed-variable'],
'priority' => 21,
];
return $tabs;
@@ -236,9 +262,16 @@ final class LicensedProductType
*/
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;
}
// 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;
}
@@ -253,7 +286,7 @@ final class LicensedProductType
global $product;
if (!$product || !$product->is_type('licensed')) {
if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
return;
}
@@ -272,11 +305,11 @@ final class LicensedProductType
{
global $product;
if (!$product || !$product->is_type('licensed')) {
if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
return;
}
/** @var LicensedProduct $product */
/** @var LicensedProduct|LicensedVariableProduct $product */
$version = $product->get_current_version();
if (empty($version)) {
@@ -289,4 +322,200 @@ final class LicensedProductType
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;
}
}