diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78166f9..547e07f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-## [0.0.1] - 2024-01-21
+## [0.0.2] - 2026-01-21
+
+### Added
+
+- Product version management UI in admin (meta box on product edit page)
+- AJAX-based version CRUD operations (add, delete, toggle active status)
+- ProductVersion model and VersionManager for version data handling
+- Email notifications with license keys on order completion
+- License information included in WooCommerce order completed emails
+- Rate limiting for REST API endpoints (30 requests/minute per IP)
+- Cloudflare and proxy-aware IP detection for rate limiting
+- JavaScript for version management interactions
+
+### Changed
+
+- Declared WooCommerce HPOS and cart/checkout blocks compatibility
+- Plugin name changed from "WC Licensed Product" to "WooCommerce Licensed Product"
+
+### Technical Details
+
+- New classes: ProductVersion, VersionManager, VersionAdminController, LicenseEmailController
+- Rate limiting uses WordPress transients for request counting
+- HTTP 429 response with Retry-After header when rate limited
+
+## [0.0.1] - 2026-01-21
### Added
@@ -44,5 +68,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- WordPress REST API integration
- Custom WooCommerce product type extending WC_Product
-[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.1...HEAD
+[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.2...HEAD
+[0.0.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.1...v0.0.2
[0.0.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/releases/tag/v0.0.1
diff --git a/assets/css/admin.css b/assets/css/admin.css
index 7008182..a2ae274 100644
--- a/assets/css/admin.css
+++ b/assets/css/admin.css
@@ -89,3 +89,59 @@
.column-license .dashicons-admin-network {
color: #2271b1;
}
+
+/* Version Status Badges */
+.version-status {
+ display: inline-block;
+ padding: 0.2em 0.5em;
+ font-size: 0.85em;
+ font-weight: 500;
+ line-height: 1.2;
+ border-radius: 3px;
+}
+
+.version-status-active {
+ background-color: #d4edda;
+ color: #155724;
+}
+
+.version-status-inactive {
+ background-color: #fff3cd;
+ color: #856404;
+}
+
+/* Version Meta Box */
+.wc-licensed-product-versions .versions-add-form {
+ background: #f9f9f9;
+ padding: 15px;
+ border: 1px solid #e5e5e5;
+ border-radius: 4px;
+ margin-bottom: 20px;
+}
+
+.wc-licensed-product-versions .versions-add-form h4 {
+ margin-top: 0;
+}
+
+.wc-licensed-product-versions .form-table {
+ margin: 0;
+}
+
+.wc-licensed-product-versions .form-table th {
+ padding: 10px 10px 10px 0;
+ width: 120px;
+}
+
+.wc-licensed-product-versions .form-table td {
+ padding: 10px 0;
+}
+
+#versions-table {
+ margin-top: 10px;
+}
+
+#versions-table .no-versions td {
+ text-align: center;
+ font-style: italic;
+ color: #666;
+}
diff --git a/assets/js/versions.js b/assets/js/versions.js
new file mode 100644
index 0000000..12ff30c
--- /dev/null
+++ b/assets/js/versions.js
@@ -0,0 +1,181 @@
+/**
+ * WC Licensed Product - Version Management
+ *
+ * @package Jeremias\WcLicensedProduct
+ */
+
+(function($) {
+ 'use strict';
+
+ var WCLicensedProductVersions = {
+ init: function() {
+ this.bindEvents();
+ },
+
+ bindEvents: function() {
+ $('#add-version-btn').on('click', this.addVersion);
+ $(document).on('click', '.delete-version-btn', this.deleteVersion);
+ $(document).on('click', '.toggle-version-btn', this.toggleVersion);
+ },
+
+ addVersion: function(e) {
+ e.preventDefault();
+
+ var $btn = $(this);
+ var $spinner = $btn.siblings('.spinner');
+ var productId = $btn.data('product-id');
+ var version = $('#new_version').val().trim();
+ var downloadUrl = $('#new_download_url').val().trim();
+ var releaseNotes = $('#new_release_notes').val().trim();
+
+ // Validate version
+ if (!version) {
+ alert(wcLicensedProductVersions.strings.versionRequired);
+ return;
+ }
+
+ if (!/^\d+\.\d+\.\d+$/.test(version)) {
+ alert(wcLicensedProductVersions.strings.versionInvalid);
+ return;
+ }
+
+ $btn.prop('disabled', true);
+ $spinner.addClass('is-active');
+
+ $.ajax({
+ url: wcLicensedProductVersions.ajaxUrl,
+ type: 'POST',
+ data: {
+ action: 'wc_licensed_product_add_version',
+ nonce: wcLicensedProductVersions.nonce,
+ product_id: productId,
+ version: version,
+ download_url: downloadUrl,
+ release_notes: releaseNotes
+ },
+ success: function(response) {
+ if (response.success) {
+ // Remove "no versions" row if present
+ $('#versions-table tbody .no-versions').remove();
+
+ // Add new row to table
+ $('#versions-table tbody').prepend(response.data.html);
+
+ // Clear form
+ $('#new_version').val('');
+ $('#new_download_url').val('');
+ $('#new_release_notes').val('');
+ } else {
+ alert(response.data.message || wcLicensedProductVersions.strings.error);
+ }
+ },
+ error: function() {
+ alert(wcLicensedProductVersions.strings.error);
+ },
+ complete: function() {
+ $btn.prop('disabled', false);
+ $spinner.removeClass('is-active');
+ }
+ });
+ },
+
+ deleteVersion: function(e) {
+ e.preventDefault();
+
+ if (!confirm(wcLicensedProductVersions.strings.confirmDelete)) {
+ return;
+ }
+
+ var $btn = $(this);
+ var $row = $btn.closest('tr');
+ var versionId = $btn.data('version-id');
+
+ $btn.prop('disabled', true);
+
+ $.ajax({
+ url: wcLicensedProductVersions.ajaxUrl,
+ type: 'POST',
+ data: {
+ action: 'wc_licensed_product_delete_version',
+ nonce: wcLicensedProductVersions.nonce,
+ version_id: versionId
+ },
+ success: function(response) {
+ if (response.success) {
+ $row.fadeOut(300, function() {
+ $(this).remove();
+
+ // Show "no versions" message if table is empty
+ if ($('#versions-table tbody tr').length === 0) {
+ $('#versions-table tbody').append(
+ '
| ' +
+ 'No versions found. Add your first version above.' +
+ ' |
'
+ );
+ }
+ });
+ } else {
+ alert(response.data.message || wcLicensedProductVersions.strings.error);
+ $btn.prop('disabled', false);
+ }
+ },
+ error: function() {
+ alert(wcLicensedProductVersions.strings.error);
+ $btn.prop('disabled', false);
+ }
+ });
+ },
+
+ toggleVersion: function(e) {
+ e.preventDefault();
+
+ var $btn = $(this);
+ var $row = $btn.closest('tr');
+ var versionId = $btn.data('version-id');
+ var currentlyActive = $btn.data('active') === 1 || $btn.data('active') === '1';
+
+ $btn.prop('disabled', true);
+
+ $.ajax({
+ url: wcLicensedProductVersions.ajaxUrl,
+ type: 'POST',
+ data: {
+ action: 'wc_licensed_product_toggle_version',
+ nonce: wcLicensedProductVersions.nonce,
+ version_id: versionId,
+ currently_active: currentlyActive ? 1 : 0
+ },
+ success: function(response) {
+ if (response.success) {
+ var isActive = response.data.isActive;
+ var $status = $row.find('.version-status');
+
+ // Update status badge
+ $status
+ .removeClass('version-status-active version-status-inactive')
+ .addClass('version-status-' + (isActive ? 'active' : 'inactive'))
+ .text(isActive ? 'Active' : 'Inactive');
+
+ // Update button
+ $btn
+ .data('active', isActive ? 1 : 0)
+ .text(isActive ? 'Deactivate' : 'Activate');
+ } else {
+ alert(response.data.message || wcLicensedProductVersions.strings.error);
+ }
+ },
+ error: function() {
+ alert(wcLicensedProductVersions.strings.error);
+ },
+ complete: function() {
+ $btn.prop('disabled', false);
+ }
+ });
+ }
+ };
+
+ $(document).ready(function() {
+ WCLicensedProductVersions.init();
+ });
+
+})(jQuery);
diff --git a/src/Admin/VersionAdminController.php b/src/Admin/VersionAdminController.php
new file mode 100644
index 0000000..9728cf6
--- /dev/null
+++ b/src/Admin/VersionAdminController.php
@@ -0,0 +1,347 @@
+versionManager = $versionManager;
+ $this->registerHooks();
+ }
+
+ /**
+ * Register WordPress hooks
+ */
+ private function registerHooks(): void
+ {
+ // Add versions meta box to licensed products
+ add_action('add_meta_boxes', [$this, 'addVersionsMetaBox']);
+
+ // Handle AJAX actions for version management
+ add_action('wp_ajax_wc_licensed_product_add_version', [$this, 'ajaxAddVersion']);
+ add_action('wp_ajax_wc_licensed_product_delete_version', [$this, 'ajaxDeleteVersion']);
+ add_action('wp_ajax_wc_licensed_product_toggle_version', [$this, 'ajaxToggleVersion']);
+
+ // Enqueue scripts for product edit page
+ add_action('admin_enqueue_scripts', [$this, 'enqueueScripts']);
+ }
+
+ /**
+ * Add versions meta box to product edit page
+ */
+ public function addVersionsMetaBox(): void
+ {
+ add_meta_box(
+ 'wc_licensed_product_versions',
+ __('Product Versions', 'wc-licensed-product'),
+ [$this, 'renderVersionsMetaBox'],
+ 'product',
+ 'normal',
+ 'high'
+ );
+ }
+
+ /**
+ * Render versions meta box
+ */
+ public function renderVersionsMetaBox(\WP_Post $post): void
+ {
+ $product = wc_get_product($post->ID);
+ if (!$product || !$product->is_type('licensed')) {
+ echo '' . esc_html__('This meta box is only available for Licensed Products.', 'wc-licensed-product') . '
';
+ return;
+ }
+
+ $versions = $this->versionManager->getVersionsByProduct($post->ID);
+ wp_nonce_field('wc_licensed_product_versions', 'wc_licensed_product_versions_nonce');
+ ?>
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+ |
+
+
+
+
+ | getVersion()); ?> |
+
+ getDownloadUrl()): ?>
+
+ getDownloadUrl())); ?>
+
+
+
+
+ |
+ getReleaseNotes() ? wp_trim_words($version->getReleaseNotes(), 10) : '—'); ?> |
+
+
+ isActive() ? esc_html__('Active', 'wc-licensed-product') : esc_html__('Inactive', 'wc-licensed-product'); ?>
+
+ |
+ getReleasedAt()->format(get_option('date_format'))); ?> |
+
+
+
+ |
+
+
+
+
+
+
+ post_type !== 'product') {
+ return;
+ }
+
+ wp_enqueue_script(
+ 'wc-licensed-product-versions',
+ WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/js/versions.js',
+ ['jquery'],
+ WC_LICENSED_PRODUCT_VERSION,
+ true
+ );
+
+ wp_localize_script('wc-licensed-product-versions', 'wcLicensedProductVersions', [
+ 'ajaxUrl' => admin_url('admin-ajax.php'),
+ 'nonce' => wp_create_nonce('wc_licensed_product_versions'),
+ 'strings' => [
+ 'confirmDelete' => __('Are you sure you want to delete this version?', 'wc-licensed-product'),
+ 'versionRequired' => __('Please enter a version number.', 'wc-licensed-product'),
+ 'versionInvalid' => __('Please enter a valid version number (e.g., 1.0.0).', 'wc-licensed-product'),
+ 'error' => __('An error occurred. Please try again.', 'wc-licensed-product'),
+ ],
+ ]);
+
+ wp_enqueue_style(
+ 'wc-licensed-product-admin',
+ WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/admin.css',
+ [],
+ WC_LICENSED_PRODUCT_VERSION
+ );
+ }
+
+ /**
+ * AJAX handler for adding a version
+ */
+ public function ajaxAddVersion(): void
+ {
+ check_ajax_referer('wc_licensed_product_versions', 'nonce');
+
+ if (!current_user_can('manage_woocommerce')) {
+ wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]);
+ }
+
+ $productId = absint($_POST['product_id'] ?? 0);
+ $version = sanitize_text_field($_POST['version'] ?? '');
+ $downloadUrl = esc_url_raw($_POST['download_url'] ?? '');
+ $releaseNotes = sanitize_textarea_field($_POST['release_notes'] ?? '');
+
+ if (!$productId || !$version) {
+ wp_send_json_error(['message' => __('Product ID and version are required.', 'wc-licensed-product')]);
+ }
+
+ // Validate version format
+ if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
+ wp_send_json_error(['message' => __('Invalid version format. Use semantic versioning (e.g., 1.0.0).', 'wc-licensed-product')]);
+ }
+
+ // Check if version already exists
+ if ($this->versionManager->versionExists($productId, $version)) {
+ wp_send_json_error(['message' => __('This version already exists.', 'wc-licensed-product')]);
+ }
+
+ $newVersion = $this->versionManager->createVersion(
+ $productId,
+ $version,
+ $releaseNotes ?: null,
+ $downloadUrl ?: null
+ );
+
+ if (!$newVersion) {
+ wp_send_json_error(['message' => __('Failed to create version.', 'wc-licensed-product')]);
+ }
+
+ // Also update the product's current version meta
+ update_post_meta($productId, '_licensed_current_version', $version);
+
+ wp_send_json_success([
+ 'message' => __('Version added successfully.', 'wc-licensed-product'),
+ 'version' => $newVersion->toArray(),
+ 'html' => $this->getVersionRowHtml($newVersion),
+ ]);
+ }
+
+ /**
+ * AJAX handler for deleting a version
+ */
+ public function ajaxDeleteVersion(): void
+ {
+ check_ajax_referer('wc_licensed_product_versions', 'nonce');
+
+ if (!current_user_can('manage_woocommerce')) {
+ wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]);
+ }
+
+ $versionId = absint($_POST['version_id'] ?? 0);
+
+ if (!$versionId) {
+ wp_send_json_error(['message' => __('Version ID is required.', 'wc-licensed-product')]);
+ }
+
+ $result = $this->versionManager->deleteVersion($versionId);
+
+ if (!$result) {
+ wp_send_json_error(['message' => __('Failed to delete version.', 'wc-licensed-product')]);
+ }
+
+ wp_send_json_success(['message' => __('Version deleted successfully.', 'wc-licensed-product')]);
+ }
+
+ /**
+ * AJAX handler for toggling version status
+ */
+ public function ajaxToggleVersion(): void
+ {
+ check_ajax_referer('wc_licensed_product_versions', 'nonce');
+
+ if (!current_user_can('manage_woocommerce')) {
+ wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]);
+ }
+
+ $versionId = absint($_POST['version_id'] ?? 0);
+ $currentlyActive = (bool) ($_POST['currently_active'] ?? false);
+
+ if (!$versionId) {
+ wp_send_json_error(['message' => __('Version ID is required.', 'wc-licensed-product')]);
+ }
+
+ $result = $this->versionManager->updateVersion($versionId, null, null, !$currentlyActive);
+
+ if (!$result) {
+ wp_send_json_error(['message' => __('Failed to update version.', 'wc-licensed-product')]);
+ }
+
+ wp_send_json_success([
+ 'message' => __('Version updated successfully.', 'wc-licensed-product'),
+ 'isActive' => !$currentlyActive,
+ ]);
+ }
+
+ /**
+ * Get HTML for a version table row
+ */
+ private function getVersionRowHtml(\Jeremias\WcLicensedProduct\Product\ProductVersion $version): string
+ {
+ ob_start();
+ ?>
+
+ | getVersion()); ?> |
+
+ getDownloadUrl()): ?>
+
+ getDownloadUrl())); ?>
+
+
+
+
+ |
+ getReleaseNotes() ? wp_trim_words($version->getReleaseNotes(), 10) : '—'); ?> |
+
+
+ isActive() ? esc_html__('Active', 'wc-licensed-product') : esc_html__('Inactive', 'wc-licensed-product'); ?>
+
+ |
+ getReleasedAt()->format(get_option('date_format'))); ?> |
+
+
+
+ |
+
+ getClientIp();
+ $transientKey = 'wclp_rate_' . md5($ip);
+
+ $data = get_transient($transientKey);
+
+ if ($data === false) {
+ // First request, start counting
+ set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
+ return null;
+ }
+
+ $count = (int) ($data['count'] ?? 0);
+ $start = (int) ($data['start'] ?? time());
+
+ // Check if window has expired
+ if (time() - $start >= self::RATE_LIMIT_WINDOW) {
+ // Reset counter
+ set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
+ return null;
+ }
+
+ // Check if limit exceeded
+ if ($count >= self::RATE_LIMIT_REQUESTS) {
+ $retryAfter = self::RATE_LIMIT_WINDOW - (time() - $start);
+ $response = new WP_REST_Response([
+ 'success' => false,
+ 'error' => 'rate_limit_exceeded',
+ 'message' => __('Too many requests. Please try again later.', 'wc-licensed-product'),
+ 'retry_after' => $retryAfter,
+ ], 429);
+ $response->header('Retry-After', (string) $retryAfter);
+ return $response;
+ }
+
+ // Increment counter
+ set_transient($transientKey, ['count' => $count + 1, 'start' => $start], self::RATE_LIMIT_WINDOW);
+ return null;
+ }
+
+ /**
+ * Get client IP address
+ */
+ private function getClientIp(): string
+ {
+ $headers = [
+ 'HTTP_CF_CONNECTING_IP', // Cloudflare
+ 'HTTP_X_FORWARDED_FOR',
+ 'HTTP_X_REAL_IP',
+ 'REMOTE_ADDR',
+ ];
+
+ foreach ($headers as $header) {
+ if (!empty($_SERVER[$header])) {
+ $ips = explode(',', $_SERVER[$header]);
+ $ip = trim($ips[0]);
+ if (filter_var($ip, FILTER_VALIDATE_IP)) {
+ return $ip;
+ }
+ }
+ }
+
+ return '0.0.0.0';
+ }
+
/**
* Register REST API routes
*/
@@ -125,6 +206,11 @@ final class RestApiController
*/
public function validateLicense(WP_REST_Request $request): WP_REST_Response
{
+ $rateLimitResponse = $this->checkRateLimit();
+ if ($rateLimitResponse !== null) {
+ return $rateLimitResponse;
+ }
+
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
@@ -140,6 +226,11 @@ final class RestApiController
*/
public function checkStatus(WP_REST_Request $request): WP_REST_Response
{
+ $rateLimitResponse = $this->checkRateLimit();
+ if ($rateLimitResponse !== null) {
+ return $rateLimitResponse;
+ }
+
$licenseKey = $request->get_param('license_key');
$license = $this->licenseManager->getLicenseByKey($licenseKey);
@@ -166,6 +257,11 @@ final class RestApiController
*/
public function activateLicense(WP_REST_Request $request): WP_REST_Response
{
+ $rateLimitResponse = $this->checkRateLimit();
+ if ($rateLimitResponse !== null) {
+ return $rateLimitResponse;
+ }
+
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
@@ -228,6 +324,11 @@ final class RestApiController
*/
public function deactivateLicense(WP_REST_Request $request): WP_REST_Response
{
+ $rateLimitResponse = $this->checkRateLimit();
+ if ($rateLimitResponse !== null) {
+ return $rateLimitResponse;
+ }
+
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
diff --git a/src/Email/LicenseEmailController.php b/src/Email/LicenseEmailController.php
new file mode 100644
index 0000000..d413c54
--- /dev/null
+++ b/src/Email/LicenseEmailController.php
@@ -0,0 +1,198 @@
+licenseManager = $licenseManager;
+ $this->registerHooks();
+ }
+
+ /**
+ * Register WordPress hooks
+ */
+ private function registerHooks(): void
+ {
+ // Add license info to order completed email
+ add_action('woocommerce_email_after_order_table', [$this, 'addLicenseInfoToEmail'], 20, 4);
+
+ // Add license info to order details in emails
+ add_action('woocommerce_order_item_meta_end', [$this, 'addLicenseToOrderItem'], 10, 4);
+ }
+
+ /**
+ * Add license information to order completed email
+ */
+ public function addLicenseInfoToEmail(\WC_Order $order, bool $sentToAdmin, bool $plainText, $email): void
+ {
+ // Only add to completed order email sent to customer
+ if ($sentToAdmin || !$email || $email->id !== 'customer_completed_order') {
+ return;
+ }
+
+ $licenses = $this->getLicensesForOrder($order);
+ if (empty($licenses)) {
+ return;
+ }
+
+ if ($plainText) {
+ $this->renderPlainTextLicenseInfo($licenses, $order);
+ } else {
+ $this->renderHtmlLicenseInfo($licenses, $order);
+ }
+ }
+
+ /**
+ * Add license key to order item in email
+ */
+ public function addLicenseToOrderItem(int $itemId, \WC_Order_Item $item, \WC_Order $order, bool $plainText): void
+ {
+ $product = $item->get_product();
+ if (!$product || !$product->is_type('licensed')) {
+ return;
+ }
+
+ $license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id());
+ if (!$license) {
+ return;
+ }
+
+ if ($plainText) {
+ echo "\n" . esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($license->getLicenseKey()) . "\n";
+ } else {
+ ?>
+
+
+
+ getLicenseKey()); ?>
+
+
+ get_items() as $item) {
+ $product = $item->get_product();
+ if ($product && $product->is_type('licensed')) {
+ $license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id());
+ if ($license) {
+ $licenses[] = [
+ 'license' => $license,
+ 'product_name' => $product->get_name(),
+ ];
+ }
+ }
+ }
+
+ return $licenses;
+ }
+
+ /**
+ * Render license info in HTML format
+ */
+ private function renderHtmlLicenseInfo(array $licenses, \WC_Order $order): void
+ {
+ $domain = $order->get_meta('_licensed_product_domain');
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+ |
+
+
+ getLicenseKey()); ?>
+
+ |
+
+ getExpiresAt();
+ echo $expiresAt
+ ? esc_html($expiresAt->format(get_option('date_format')))
+ : esc_html__('Never', 'wc-licensed-product');
+ ?>
+ |
+
+
+
+
+
+
+
+
+
+ get_meta('_licensed_product_domain');
+
+ echo "\n\n";
+ echo "==========================================================\n";
+ echo esc_html__('YOUR LICENSE KEYS', 'wc-licensed-product') . "\n";
+ echo "==========================================================\n\n";
+
+ if ($domain) {
+ echo esc_html__('Licensed Domain:', 'wc-licensed-product') . ' ' . esc_html($domain) . "\n\n";
+ }
+
+ foreach ($licenses as $item) {
+ echo esc_html($item['product_name']) . "\n";
+ echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($item['license']->getLicenseKey()) . "\n";
+
+ $expiresAt = $item['license']->getExpiresAt();
+ echo esc_html__('Expires:', 'wc-licensed-product') . ' ';
+ echo $expiresAt
+ ? esc_html($expiresAt->format(get_option('date_format')))
+ : esc_html__('Never', 'wc-licensed-product');
+ echo "\n\n";
+ }
+
+ echo esc_html__('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product') . "\n";
+ echo "==========================================================\n\n";
+ }
+}
diff --git a/src/Plugin.php b/src/Plugin.php
index dfa51d9..3d7062f 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -10,11 +10,14 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct;
use Jeremias\WcLicensedProduct\Admin\AdminController;
+use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
use Jeremias\WcLicensedProduct\Api\RestApiController;
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
+use Jeremias\WcLicensedProduct\Email\LicenseEmailController;
use Jeremias\WcLicensedProduct\Frontend\AccountController;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
+use Jeremias\WcLicensedProduct\Product\VersionManager;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
@@ -38,6 +41,11 @@ final class Plugin
*/
private LicenseManager $licenseManager;
+ /**
+ * Version manager
+ */
+ private VersionManager $versionManager;
+
/**
* Get singleton instance
*/
@@ -90,15 +98,18 @@ final class Plugin
private function initComponents(): void
{
$this->licenseManager = new LicenseManager();
+ $this->versionManager = new VersionManager();
// Initialize controllers
new LicensedProductType();
new CheckoutController($this->licenseManager);
new AccountController($this->twig, $this->licenseManager);
new RestApiController($this->licenseManager);
+ new LicenseEmailController($this->licenseManager);
if (is_admin()) {
new AdminController($this->twig, $this->licenseManager);
+ new VersionAdminController($this->versionManager);
}
}
diff --git a/src/Product/ProductVersion.php b/src/Product/ProductVersion.php
new file mode 100644
index 0000000..69d2b85
--- /dev/null
+++ b/src/Product/ProductVersion.php
@@ -0,0 +1,137 @@
+id = (int) $data['id'];
+ $version->productId = (int) $data['product_id'];
+ $version->version = $data['version'];
+ $version->majorVersion = (int) $data['major_version'];
+ $version->minorVersion = (int) $data['minor_version'];
+ $version->patchVersion = (int) $data['patch_version'];
+ $version->releaseNotes = $data['release_notes'] ?: null;
+ $version->downloadUrl = $data['download_url'] ?: null;
+ $version->isActive = (bool) $data['is_active'];
+ $version->releasedAt = new \DateTimeImmutable($data['released_at']);
+ $version->createdAt = new \DateTimeImmutable($data['created_at']);
+
+ return $version;
+ }
+
+ /**
+ * Parse version string into components
+ */
+ public static function parseVersion(string $versionString): array
+ {
+ $parts = explode('.', $versionString);
+ return [
+ 'major' => (int) ($parts[0] ?? 0),
+ 'minor' => (int) ($parts[1] ?? 0),
+ 'patch' => (int) ($parts[2] ?? 0),
+ ];
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function getProductId(): int
+ {
+ return $this->productId;
+ }
+
+ public function getVersion(): string
+ {
+ return $this->version;
+ }
+
+ public function getMajorVersion(): int
+ {
+ return $this->majorVersion;
+ }
+
+ public function getMinorVersion(): int
+ {
+ return $this->minorVersion;
+ }
+
+ public function getPatchVersion(): int
+ {
+ return $this->patchVersion;
+ }
+
+ public function getReleaseNotes(): ?string
+ {
+ return $this->releaseNotes;
+ }
+
+ public function getDownloadUrl(): ?string
+ {
+ return $this->downloadUrl;
+ }
+
+ public function isActive(): bool
+ {
+ return $this->isActive;
+ }
+
+ public function getReleasedAt(): \DateTimeInterface
+ {
+ return $this->releasedAt;
+ }
+
+ public function getCreatedAt(): \DateTimeInterface
+ {
+ return $this->createdAt;
+ }
+
+ /**
+ * Convert to array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'product_id' => $this->productId,
+ 'version' => $this->version,
+ 'major_version' => $this->majorVersion,
+ 'minor_version' => $this->minorVersion,
+ 'patch_version' => $this->patchVersion,
+ 'release_notes' => $this->releaseNotes,
+ 'download_url' => $this->downloadUrl,
+ 'is_active' => $this->isActive,
+ 'released_at' => $this->releasedAt->format('Y-m-d H:i:s'),
+ 'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
+ ];
+ }
+}
diff --git a/src/Product/VersionManager.php b/src/Product/VersionManager.php
new file mode 100644
index 0000000..e622e23
--- /dev/null
+++ b/src/Product/VersionManager.php
@@ -0,0 +1,209 @@
+get_row(
+ $wpdb->prepare("SELECT * FROM {$tableName} WHERE id = %d", $id),
+ ARRAY_A
+ );
+
+ return $row ? ProductVersion::fromArray($row) : null;
+ }
+
+ /**
+ * Get all versions for a product
+ */
+ public function getVersionsByProduct(int $productId): array
+ {
+ global $wpdb;
+
+ $tableName = Installer::getVersionsTable();
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$tableName} WHERE product_id = %d ORDER BY major_version DESC, minor_version DESC, patch_version DESC",
+ $productId
+ ),
+ ARRAY_A
+ );
+
+ return array_map(fn(array $row) => ProductVersion::fromArray($row), $rows ?: []);
+ }
+
+ /**
+ * Get latest active version for a product
+ */
+ public function getLatestVersion(int $productId): ?ProductVersion
+ {
+ global $wpdb;
+
+ $tableName = Installer::getVersionsTable();
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT * FROM {$tableName} WHERE product_id = %d AND is_active = 1 ORDER BY major_version DESC, minor_version DESC, patch_version DESC LIMIT 1",
+ $productId
+ ),
+ ARRAY_A
+ );
+
+ return $row ? ProductVersion::fromArray($row) : null;
+ }
+
+ /**
+ * Get latest version for a specific major version
+ */
+ public function getLatestVersionForMajor(int $productId, int $majorVersion): ?ProductVersion
+ {
+ global $wpdb;
+
+ $tableName = Installer::getVersionsTable();
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT * FROM {$tableName} WHERE product_id = %d AND major_version = %d AND is_active = 1 ORDER BY minor_version DESC, patch_version DESC LIMIT 1",
+ $productId,
+ $majorVersion
+ ),
+ ARRAY_A
+ );
+
+ return $row ? ProductVersion::fromArray($row) : null;
+ }
+
+ /**
+ * Create a new version
+ */
+ public function createVersion(
+ int $productId,
+ string $version,
+ ?string $releaseNotes = null,
+ ?string $downloadUrl = null
+ ): ?ProductVersion {
+ global $wpdb;
+
+ $parsed = ProductVersion::parseVersion($version);
+
+ $tableName = Installer::getVersionsTable();
+ $result = $wpdb->insert(
+ $tableName,
+ [
+ 'product_id' => $productId,
+ 'version' => $version,
+ 'major_version' => $parsed['major'],
+ 'minor_version' => $parsed['minor'],
+ 'patch_version' => $parsed['patch'],
+ 'release_notes' => $releaseNotes,
+ 'download_url' => $downloadUrl,
+ 'is_active' => 1,
+ ],
+ ['%d', '%s', '%d', '%d', '%d', '%s', '%s', '%d']
+ );
+
+ if ($result === false) {
+ return null;
+ }
+
+ return $this->getVersionById((int) $wpdb->insert_id);
+ }
+
+ /**
+ * Update a version
+ */
+ public function updateVersion(
+ int $versionId,
+ ?string $releaseNotes = null,
+ ?string $downloadUrl = null,
+ ?bool $isActive = null
+ ): bool {
+ global $wpdb;
+
+ $data = [];
+ $formats = [];
+
+ if ($releaseNotes !== null) {
+ $data['release_notes'] = $releaseNotes;
+ $formats[] = '%s';
+ }
+
+ if ($downloadUrl !== null) {
+ $data['download_url'] = $downloadUrl;
+ $formats[] = '%s';
+ }
+
+ if ($isActive !== null) {
+ $data['is_active'] = $isActive ? 1 : 0;
+ $formats[] = '%d';
+ }
+
+ if (empty($data)) {
+ return true;
+ }
+
+ $tableName = Installer::getVersionsTable();
+ $result = $wpdb->update(
+ $tableName,
+ $data,
+ ['id' => $versionId],
+ $formats,
+ ['%d']
+ );
+
+ return $result !== false;
+ }
+
+ /**
+ * Delete a version
+ */
+ public function deleteVersion(int $versionId): bool
+ {
+ global $wpdb;
+
+ $tableName = Installer::getVersionsTable();
+ $result = $wpdb->delete(
+ $tableName,
+ ['id' => $versionId],
+ ['%d']
+ );
+
+ return $result !== false;
+ }
+
+ /**
+ * Check if version exists for product
+ */
+ public function versionExists(int $productId, string $version): bool
+ {
+ global $wpdb;
+
+ $tableName = Installer::getVersionsTable();
+ $count = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$tableName} WHERE product_id = %d AND version = %s",
+ $productId,
+ $version
+ )
+ );
+
+ return (int) $count > 0;
+ }
+}
diff --git a/wc-licensed-product.php b/wc-licensed-product.php
index daba5f6..91fecd5 100644
--- a/wc-licensed-product.php
+++ b/wc-licensed-product.php
@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce 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.
- * Version: 0.0.1
+ * Version: 0.0.2
* Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
}
// Plugin constants
-define('WC_LICENSED_PRODUCT_VERSION', '0.0.1');
+define('WC_LICENSED_PRODUCT_VERSION', '0.0.2');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));