Implement version 0.0.1 - Licensed Product type for WooCommerce

Add complete plugin infrastructure for selling software with license keys:

- New "Licensed Product" WooCommerce product type
- License key generation (XXXX-XXXX-XXXX-XXXX format) on order completion
- Domain-based license validation system
- REST API endpoints (validate, status, activate, deactivate)
- Customer My Account "Licenses" page
- Admin license management under WooCommerce > Licenses
- Checkout domain field for licensed products
- Custom database tables for licenses and product versions
- Twig template engine integration
- Full i18n support with German (de_CH) translation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 18:55:18 +01:00
parent 8a4802248c
commit 404083f023
22 changed files with 3746 additions and 0 deletions

View File

@@ -0,0 +1,412 @@
<?php
/**
* Admin Controller
*
* @package Jeremias\WcLicensedProduct\Admin
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Admin;
use Jeremias\WcLicensedProduct\License\License;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use Twig\Environment;
/**
* Handles admin pages for license management
*/
final class AdminController
{
private Environment $twig;
private LicenseManager $licenseManager;
public function __construct(Environment $twig, LicenseManager $licenseManager)
{
$this->twig = $twig;
$this->licenseManager = $licenseManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
// Add admin menu
add_action('admin_menu', [$this, 'addAdminMenu']);
// Enqueue admin styles
add_action('admin_enqueue_scripts', [$this, 'enqueueStyles']);
// Handle admin actions
add_action('admin_init', [$this, 'handleAdminActions']);
// Add licenses column to orders list
add_filter('manage_edit-shop_order_columns', [$this, 'addOrdersLicenseColumn']);
add_action('manage_shop_order_posts_custom_column', [$this, 'displayOrdersLicenseColumn'], 10, 2);
// HPOS compatibility
add_filter('woocommerce_shop_order_list_table_columns', [$this, 'addOrdersLicenseColumn']);
add_action('woocommerce_shop_order_list_table_custom_column', [$this, 'displayOrdersLicenseColumnHpos'], 10, 2);
}
/**
* Add admin menu pages
*/
public function addAdminMenu(): void
{
add_submenu_page(
'woocommerce',
__('Licenses', 'wc-licensed-product'),
__('Licenses', 'wc-licensed-product'),
'manage_woocommerce',
'wc-licenses',
[$this, 'renderLicensesPage']
);
}
/**
* Enqueue admin styles and scripts
*/
public function enqueueStyles(string $hook): void
{
if ($hook !== 'woocommerce_page_wc-licenses') {
return;
}
wp_enqueue_style(
'wc-licensed-product-admin',
WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/admin.css',
[],
WC_LICENSED_PRODUCT_VERSION
);
}
/**
* Handle admin actions (update, delete licenses)
*/
public function handleAdminActions(): void
{
if (!isset($_GET['page']) || $_GET['page'] !== 'wc-licenses') {
return;
}
if (!current_user_can('manage_woocommerce')) {
return;
}
// Handle status update
if (isset($_POST['action']) && $_POST['action'] === 'update_license_status') {
$this->handleStatusUpdate();
}
// Handle delete
if (isset($_GET['action']) && $_GET['action'] === 'delete' && isset($_GET['license_id'])) {
$this->handleDelete();
}
// Handle revoke
if (isset($_GET['action']) && $_GET['action'] === 'revoke' && isset($_GET['license_id'])) {
$this->handleRevoke();
}
}
/**
* Handle license status update
*/
private function handleStatusUpdate(): void
{
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'update_license_status')) {
wp_die(__('Security check failed.', 'wc-licensed-product'));
}
$licenseId = absint($_POST['license_id'] ?? 0);
$status = sanitize_text_field($_POST['status'] ?? '');
if ($licenseId && in_array($status, [License::STATUS_ACTIVE, License::STATUS_INACTIVE, License::STATUS_REVOKED], true)) {
$this->licenseManager->updateLicenseStatus($licenseId, $status);
wp_redirect(admin_url('admin.php?page=wc-licenses&updated=1'));
exit;
}
}
/**
* Handle license deletion
*/
private function handleDelete(): void
{
if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'delete_license')) {
wp_die(__('Security check failed.', 'wc-licensed-product'));
}
$licenseId = absint($_GET['license_id'] ?? 0);
if ($licenseId) {
$this->licenseManager->deleteLicense($licenseId);
wp_redirect(admin_url('admin.php?page=wc-licenses&deleted=1'));
exit;
}
}
/**
* Handle license revocation
*/
private function handleRevoke(): void
{
if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'revoke_license')) {
wp_die(__('Security check failed.', 'wc-licensed-product'));
}
$licenseId = absint($_GET['license_id'] ?? 0);
if ($licenseId) {
$this->licenseManager->updateLicenseStatus($licenseId, License::STATUS_REVOKED);
wp_redirect(admin_url('admin.php?page=wc-licenses&revoked=1'));
exit;
}
}
/**
* Render licenses admin page
*/
public function renderLicensesPage(): void
{
$page = isset($_GET['paged']) ? absint($_GET['paged']) : 1;
$perPage = 20;
$licenses = $this->licenseManager->getAllLicenses($page, $perPage);
$totalLicenses = $this->licenseManager->getLicenseCount();
$totalPages = ceil($totalLicenses / $perPage);
// Enrich licenses with related data
$enrichedLicenses = [];
foreach ($licenses as $license) {
$product = wc_get_product($license->getProductId());
$order = wc_get_order($license->getOrderId());
$customer = get_userdata($license->getCustomerId());
$enrichedLicenses[] = [
'license' => $license,
'product_name' => $product ? $product->get_name() : __('Unknown', 'wc-licensed-product'),
'product_edit_url' => $product ? get_edit_post_link($product->get_id()) : '',
'order_number' => $order ? $order->get_order_number() : '',
'order_edit_url' => $order ? $order->get_edit_order_url() : '',
'customer_name' => $customer ? $customer->display_name : __('Guest', 'wc-licensed-product'),
'customer_email' => $customer ? $customer->user_email : '',
];
}
try {
echo $this->twig->render('admin/licenses.html.twig', [
'licenses' => $enrichedLicenses,
'current_page' => $page,
'total_pages' => $totalPages,
'total_licenses' => $totalLicenses,
'admin_url' => admin_url('admin.php?page=wc-licenses'),
'notices' => $this->getNotices(),
]);
} catch (\Exception $e) {
// Fallback to PHP template
$this->renderLicensesPageFallback($enrichedLicenses, $page, $totalPages, $totalLicenses);
}
}
/**
* Get admin notices
*/
private function getNotices(): array
{
$notices = [];
if (isset($_GET['updated'])) {
$notices[] = ['type' => 'success', 'message' => __('License updated successfully.', 'wc-licensed-product')];
}
if (isset($_GET['deleted'])) {
$notices[] = ['type' => 'success', 'message' => __('License deleted successfully.', 'wc-licensed-product')];
}
if (isset($_GET['revoked'])) {
$notices[] = ['type' => 'success', 'message' => __('License revoked successfully.', 'wc-licensed-product')];
}
return $notices;
}
/**
* Fallback render for licenses page
*/
private function renderLicensesPageFallback(array $enrichedLicenses, int $page, int $totalPages, int $totalLicenses): void
{
?>
<div class="wrap">
<h1><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h1>
<?php foreach ($this->getNotices() as $notice): ?>
<div class="notice notice-<?php echo esc_attr($notice['type']); ?> is-dismissible">
<p><?php echo esc_html($notice['message']); ?></p>
</div>
<?php endforeach; ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Product', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Customer', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Actions', 'wc-licensed-product'); ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($enrichedLicenses)): ?>
<tr>
<td colspan="7"><?php esc_html_e('No licenses found.', 'wc-licensed-product'); ?></td>
</tr>
<?php else: ?>
<?php foreach ($enrichedLicenses as $item): ?>
<tr>
<td><code><?php echo esc_html($item['license']->getLicenseKey()); ?></code></td>
<td>
<?php if ($item['product_edit_url']): ?>
<a href="<?php echo esc_url($item['product_edit_url']); ?>">
<?php echo esc_html($item['product_name']); ?>
</a>
<?php else: ?>
<?php echo esc_html($item['product_name']); ?>
<?php endif; ?>
</td>
<td>
<?php echo esc_html($item['customer_name']); ?>
<?php if ($item['customer_email']): ?>
<br><small><?php echo esc_html($item['customer_email']); ?></small>
<?php endif; ?>
</td>
<td><?php echo esc_html($item['license']->getDomain()); ?></td>
<td>
<span class="license-status license-status-<?php echo esc_attr($item['license']->getStatus()); ?>">
<?php echo esc_html(ucfirst($item['license']->getStatus())); ?>
</span>
</td>
<td>
<?php
$expiresAt = $item['license']->getExpiresAt();
echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product');
?>
</td>
<td>
<?php if ($item['license']->getStatus() !== License::STATUS_REVOKED): ?>
<a href="<?php echo esc_url(wp_nonce_url(
admin_url('admin.php?page=wc-licenses&action=revoke&license_id=' . $item['license']->getId()),
'revoke_license'
)); ?>" class="button button-small" onclick="return confirm('<?php esc_attr_e('Are you sure?', 'wc-licensed-product'); ?>')">
<?php esc_html_e('Revoke', 'wc-licensed-product'); ?>
</a>
<?php endif; ?>
<a href="<?php echo esc_url(wp_nonce_url(
admin_url('admin.php?page=wc-licenses&action=delete&license_id=' . $item['license']->getId()),
'delete_license'
)); ?>" class="button button-small button-link-delete" onclick="return confirm('<?php esc_attr_e('Are you sure you want to delete this license?', 'wc-licensed-product'); ?>')">
<?php esc_html_e('Delete', 'wc-licensed-product'); ?>
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<?php if ($totalPages > 1): ?>
<div class="tablenav bottom">
<div class="tablenav-pages">
<?php
echo paginate_links([
'base' => admin_url('admin.php?page=wc-licenses&paged=%#%'),
'format' => '',
'current' => $page,
'total' => $totalPages,
]);
?>
</div>
</div>
<?php endif; ?>
</div>
<?php
}
/**
* Add license column to orders list
*/
public function addOrdersLicenseColumn(array $columns): array
{
$newColumns = [];
foreach ($columns as $key => $value) {
$newColumns[$key] = $value;
if ($key === 'order_status') {
$newColumns['license'] = __('License', 'wc-licensed-product');
}
}
return $newColumns;
}
/**
* Display license column content
*/
public function displayOrdersLicenseColumn(string $column, int $postId): void
{
if ($column !== 'license') {
return;
}
$order = wc_get_order($postId);
$this->outputLicenseColumnContent($order);
}
/**
* Display license column content (HPOS)
*/
public function displayOrdersLicenseColumnHpos(string $column, \WC_Order $order): void
{
if ($column !== 'license') {
return;
}
$this->outputLicenseColumnContent($order);
}
/**
* Output license column content
*/
private function outputLicenseColumnContent(?\WC_Order $order): void
{
if (!$order) {
echo '—';
return;
}
$hasLicensedProduct = false;
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && $product->is_type('licensed')) {
$hasLicensedProduct = true;
break;
}
}
if (!$hasLicensedProduct) {
echo '—';
return;
}
$domain = $order->get_meta('_licensed_product_domain');
if ($domain) {
echo '<span class="dashicons dashicons-admin-network"></span> ' . esc_html($domain);
} else {
echo '<span class="dashicons dashicons-warning" title="' . esc_attr__('No domain specified', 'wc-licensed-product') . '"></span>';
}
}
}

View File

@@ -0,0 +1,271 @@
<?php
/**
* REST API Controller
*
* @package Jeremias\WcLicensedProduct\Api
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Api;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Handles REST API endpoints for license validation
*/
final class RestApiController
{
private const NAMESPACE = 'wc-licensed-product/v1';
private LicenseManager $licenseManager;
public function __construct(LicenseManager $licenseManager)
{
$this->licenseManager = $licenseManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
add_action('rest_api_init', [$this, 'registerRoutes']);
}
/**
* Register REST API routes
*/
public function registerRoutes(): void
{
// Validate license endpoint (public)
register_rest_route(self::NAMESPACE, '/validate', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'validateLicense'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ($value): bool {
return !empty($value) && strlen($value) <= 64;
},
],
'domain' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ($value): bool {
return !empty($value) && strlen($value) <= 255;
},
],
],
]);
// Check license status endpoint (public)
register_rest_route(self::NAMESPACE, '/status', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'checkStatus'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
// Activate license on domain endpoint (public)
register_rest_route(self::NAMESPACE, '/activate', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'activateLicense'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
'domain' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
// Deactivate license endpoint (public)
register_rest_route(self::NAMESPACE, '/deactivate', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'deactivateLicense'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
'domain' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
}
/**
* Validate license endpoint
*/
public function validateLicense(WP_REST_Request $request): WP_REST_Response
{
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
$result = $this->licenseManager->validateLicense($licenseKey, $domain);
$statusCode = $result['valid'] ? 200 : 403;
return new WP_REST_Response($result, $statusCode);
}
/**
* Check license status endpoint
*/
public function checkStatus(WP_REST_Request $request): WP_REST_Response
{
$licenseKey = $request->get_param('license_key');
$license = $this->licenseManager->getLicenseByKey($licenseKey);
if (!$license) {
return new WP_REST_Response([
'valid' => false,
'error' => 'license_not_found',
'message' => __('License key not found.', 'wc-licensed-product'),
], 404);
}
return new WP_REST_Response([
'valid' => $license->isValid(),
'status' => $license->getStatus(),
'domain' => $license->getDomain(),
'expires_at' => $license->getExpiresAt()?->format('Y-m-d'),
'activations_count' => $license->getActivationsCount(),
'max_activations' => $license->getMaxActivations(),
]);
}
/**
* Activate license on domain endpoint
*/
public function activateLicense(WP_REST_Request $request): WP_REST_Response
{
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
$license = $this->licenseManager->getLicenseByKey($licenseKey);
if (!$license) {
return new WP_REST_Response([
'success' => false,
'error' => 'license_not_found',
'message' => __('License key not found.', 'wc-licensed-product'),
], 404);
}
if (!$license->isValid()) {
return new WP_REST_Response([
'success' => false,
'error' => 'license_invalid',
'message' => __('This license is not valid.', 'wc-licensed-product'),
], 403);
}
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
// Check if already activated on this domain
if ($license->getDomain() === $normalizedDomain) {
return new WP_REST_Response([
'success' => true,
'message' => __('License is already activated for this domain.', 'wc-licensed-product'),
]);
}
// Check if can activate on another domain
if (!$license->canActivate()) {
return new WP_REST_Response([
'success' => false,
'error' => 'max_activations_reached',
'message' => __('Maximum number of activations reached.', 'wc-licensed-product'),
], 403);
}
// Update domain (in this simple implementation, we replace the domain)
$success = $this->licenseManager->updateLicenseDomain($license->getId(), $domain);
if (!$success) {
return new WP_REST_Response([
'success' => false,
'error' => 'activation_failed',
'message' => __('Failed to activate license.', 'wc-licensed-product'),
], 500);
}
return new WP_REST_Response([
'success' => true,
'message' => __('License activated successfully.', 'wc-licensed-product'),
]);
}
/**
* Deactivate license endpoint
*/
public function deactivateLicense(WP_REST_Request $request): WP_REST_Response
{
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
$license = $this->licenseManager->getLicenseByKey($licenseKey);
if (!$license) {
return new WP_REST_Response([
'success' => false,
'error' => 'license_not_found',
'message' => __('License key not found.', 'wc-licensed-product'),
], 404);
}
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
// Verify domain matches
if ($license->getDomain() !== $normalizedDomain) {
return new WP_REST_Response([
'success' => false,
'error' => 'domain_mismatch',
'message' => __('License is not activated for this domain.', 'wc-licensed-product'),
], 403);
}
// Set status to inactive
$success = $this->licenseManager->updateLicenseStatus($license->getId(), 'inactive');
if (!$success) {
return new WP_REST_Response([
'success' => false,
'error' => 'deactivation_failed',
'message' => __('Failed to deactivate license.', 'wc-licensed-product'),
], 500);
}
return new WP_REST_Response([
'success' => true,
'message' => __('License deactivated successfully.', 'wc-licensed-product'),
]);
}
}

View File

@@ -0,0 +1,207 @@
<?php
/**
* Checkout Controller
*
* @package Jeremias\WcLicensedProduct\Checkout
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Checkout;
use Jeremias\WcLicensedProduct\License\LicenseManager;
/**
* Handles checkout modifications for licensed products
*/
final class CheckoutController
{
private LicenseManager $licenseManager;
public function __construct(LicenseManager $licenseManager)
{
$this->licenseManager = $licenseManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
// Add domain field to checkout
add_action('woocommerce_after_order_notes', [$this, 'addDomainField']);
// Validate domain field
add_action('woocommerce_checkout_process', [$this, 'validateDomainField']);
// Save domain field to order meta
add_action('woocommerce_checkout_update_order_meta', [$this, 'saveDomainField']);
// Display domain in order details (admin)
add_action('woocommerce_admin_order_data_after_billing_address', [$this, 'displayDomainInAdmin']);
// Display domain in order email
add_action('woocommerce_email_after_order_table', [$this, 'displayDomainInEmail'], 10, 3);
}
/**
* Check if cart contains licensed products
*/
private function cartHasLicensedProducts(): bool
{
if (!WC()->cart) {
return false;
}
foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data'];
if ($product && $product->is_type('licensed')) {
return true;
}
}
return false;
}
/**
* Add domain field to checkout form
*/
public function addDomainField(): void
{
if (!$this->cartHasLicensedProducts()) {
return;
}
?>
<div id="licensed-product-domain-field">
<h3><?php esc_html_e('License Domain', 'wc-licensed-product'); ?></h3>
<p class="form-row form-row-wide">
<label for="licensed_product_domain">
<?php esc_html_e('Domain for License Activation', 'wc-licensed-product'); ?>
<abbr class="required" title="<?php esc_attr_e('required', 'wc-licensed-product'); ?>">*</abbr>
</label>
<input
type="text"
class="input-text"
name="licensed_product_domain"
id="licensed_product_domain"
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>"
value="<?php echo esc_attr(WC()->checkout->get_value('licensed_product_domain')); ?>"
/>
<span class="description">
<?php esc_html_e('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'); ?>
</span>
</p>
</div>
<?php
}
/**
* Validate domain field during checkout
*/
public function validateDomainField(): void
{
if (!$this->cartHasLicensedProducts()) {
return;
}
$domain = isset($_POST['licensed_product_domain'])
? sanitize_text_field($_POST['licensed_product_domain'])
: '';
if (empty($domain)) {
wc_add_notice(
__('Please enter a domain for your license activation.', 'wc-licensed-product'),
'error'
);
return;
}
// Validate domain format
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
if (!$this->isValidDomain($normalizedDomain)) {
wc_add_notice(
__('Please enter a valid domain name.', 'wc-licensed-product'),
'error'
);
}
}
/**
* Save domain field to order meta
*/
public function saveDomainField(int $orderId): void
{
if (!$this->cartHasLicensedProducts()) {
return;
}
if (isset($_POST['licensed_product_domain']) && !empty($_POST['licensed_product_domain'])) {
$domain = sanitize_text_field($_POST['licensed_product_domain']);
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
$order = wc_get_order($orderId);
if ($order) {
$order->update_meta_data('_licensed_product_domain', $normalizedDomain);
$order->save();
}
}
}
/**
* Display domain in admin order view
*/
public function displayDomainInAdmin(\WC_Order $order): void
{
$domain = $order->get_meta('_licensed_product_domain');
if (!$domain) {
return;
}
?>
<p>
<strong><?php esc_html_e('License Domain:', 'wc-licensed-product'); ?></strong>
<?php echo esc_html($domain); ?>
</p>
<?php
}
/**
* Display domain in order emails
*/
public function displayDomainInEmail(\WC_Order $order, bool $sentToAdmin, bool $plainText): void
{
$domain = $order->get_meta('_licensed_product_domain');
if (!$domain) {
return;
}
if ($plainText) {
echo "\n" . esc_html__('License Domain:', 'wc-licensed-product') . ' ' . esc_html($domain) . "\n";
} else {
?>
<p>
<strong><?php esc_html_e('License Domain:', 'wc-licensed-product'); ?></strong>
<?php echo esc_html($domain); ?>
</p>
<?php
}
}
/**
* Validate domain format
*/
private function isValidDomain(string $domain): bool
{
// Basic domain validation
if (strlen($domain) > 255) {
return false;
}
// Check for valid domain pattern
$pattern = '/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/';
return (bool) preg_match($pattern, $domain);
}
}

View File

@@ -0,0 +1,187 @@
<?php
/**
* Frontend Account Controller
*
* @package Jeremias\WcLicensedProduct\Frontend
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Frontend;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use Twig\Environment;
/**
* Handles customer account pages for viewing licenses
*/
final class AccountController
{
private Environment $twig;
private LicenseManager $licenseManager;
public function __construct(Environment $twig, LicenseManager $licenseManager)
{
$this->twig = $twig;
$this->licenseManager = $licenseManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
// Add licenses endpoint
add_action('init', [$this, 'addLicensesEndpoint']);
// Add licenses menu item
add_filter('woocommerce_account_menu_items', [$this, 'addLicensesMenuItem']);
// Add licenses endpoint content
add_action('woocommerce_account_licenses_endpoint', [$this, 'displayLicensesContent']);
// Enqueue frontend styles
add_action('wp_enqueue_scripts', [$this, 'enqueueStyles']);
}
/**
* Add licenses endpoint for My Account
*/
public function addLicensesEndpoint(): void
{
add_rewrite_endpoint('licenses', EP_ROOT | EP_PAGES);
}
/**
* Add licenses menu item to My Account navigation
*/
public function addLicensesMenuItem(array $items): array
{
// Insert licenses after orders
$newItems = [];
foreach ($items as $key => $value) {
$newItems[$key] = $value;
if ($key === 'orders') {
$newItems['licenses'] = __('Licenses', 'wc-licensed-product');
}
}
return $newItems;
}
/**
* Display licenses content in My Account
*/
public function displayLicensesContent(): void
{
$customerId = get_current_user_id();
if (!$customerId) {
echo '<p>' . esc_html__('Please log in to view your licenses.', 'wc-licensed-product') . '</p>';
return;
}
$licenses = $this->licenseManager->getLicensesByCustomer($customerId);
// Enrich licenses with product data
$enrichedLicenses = [];
foreach ($licenses as $license) {
$product = wc_get_product($license->getProductId());
$order = wc_get_order($license->getOrderId());
$enrichedLicenses[] = [
'license' => $license,
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
'product_url' => $product ? $product->get_permalink() : '',
'order_number' => $order ? $order->get_order_number() : '',
'order_url' => $order ? $order->get_view_order_url() : '',
];
}
try {
echo $this->twig->render('frontend/licenses.html.twig', [
'licenses' => $enrichedLicenses,
'has_licenses' => !empty($enrichedLicenses),
]);
} catch (\Exception $e) {
// Fallback to PHP template if Twig fails
$this->displayLicensesFallback($enrichedLicenses);
}
}
/**
* Fallback display method if Twig is unavailable
*/
private function displayLicensesFallback(array $enrichedLicenses): void
{
if (empty($enrichedLicenses)) {
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
return;
}
?>
<table class="woocommerce-licenses-table shop_table shop_table_responsive">
<thead>
<tr>
<th><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Product', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($enrichedLicenses as $item): ?>
<tr>
<td data-title="<?php esc_attr_e('License Key', 'wc-licensed-product'); ?>">
<code><?php echo esc_html($item['license']->getLicenseKey()); ?></code>
</td>
<td data-title="<?php esc_attr_e('Product', 'wc-licensed-product'); ?>">
<?php if ($item['product_url']): ?>
<a href="<?php echo esc_url($item['product_url']); ?>">
<?php echo esc_html($item['product_name']); ?>
</a>
<?php else: ?>
<?php echo esc_html($item['product_name']); ?>
<?php endif; ?>
</td>
<td data-title="<?php esc_attr_e('Domain', 'wc-licensed-product'); ?>">
<?php echo esc_html($item['license']->getDomain()); ?>
</td>
<td data-title="<?php esc_attr_e('Status', 'wc-licensed-product'); ?>">
<span class="license-status license-status-<?php echo esc_attr($item['license']->getStatus()); ?>">
<?php echo esc_html(ucfirst($item['license']->getStatus())); ?>
</span>
</td>
<td data-title="<?php esc_attr_e('Expires', 'wc-licensed-product'); ?>">
<?php
$expiresAt = $item['license']->getExpiresAt();
echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product');
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
}
/**
* Enqueue frontend styles
*/
public function enqueueStyles(): void
{
if (!is_account_page()) {
return;
}
wp_enqueue_style(
'wc-licensed-product-frontend',
WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/frontend.css',
[],
WC_LICENSED_PRODUCT_VERSION
);
}
}

138
src/Installer.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
/**
* Plugin Installer
*
* @package Jeremias\WcLicensedProduct
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct;
/**
* Handles plugin activation and deactivation
*/
final class Installer
{
/**
* Database table name for licenses
*/
public const TABLE_LICENSES = 'wc_licensed_product_licenses';
/**
* Database table name for product versions
*/
public const TABLE_PRODUCT_VERSIONS = 'wc_licensed_product_versions';
/**
* Run on plugin activation
*/
public static function activate(): void
{
self::createTables();
self::createCacheDir();
// Set version in options
update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION);
// Flush rewrite rules for REST API
flush_rewrite_rules();
}
/**
* Run on plugin deactivation
*/
public static function deactivate(): void
{
// Flush rewrite rules
flush_rewrite_rules();
}
/**
* Create database tables
*/
private static function createTables(): void
{
global $wpdb;
$charsetCollate = $wpdb->get_charset_collate();
$licensesTable = $wpdb->prefix . self::TABLE_LICENSES;
$versionsTable = $wpdb->prefix . self::TABLE_PRODUCT_VERSIONS;
$sqlLicenses = "CREATE TABLE {$licensesTable} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
license_key VARCHAR(64) NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
domain VARCHAR(255) NOT NULL,
version_id BIGINT UNSIGNED DEFAULT NULL,
status ENUM('active', 'inactive', 'expired', 'revoked') NOT NULL DEFAULT 'active',
activations_count INT UNSIGNED NOT NULL DEFAULT 0,
max_activations INT UNSIGNED NOT NULL DEFAULT 1,
expires_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY license_key (license_key),
KEY order_id (order_id),
KEY product_id (product_id),
KEY customer_id (customer_id),
KEY domain (domain),
KEY status (status)
) {$charsetCollate};";
$sqlVersions = "CREATE TABLE {$versionsTable} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
version VARCHAR(32) NOT NULL,
major_version INT UNSIGNED NOT NULL,
minor_version INT UNSIGNED NOT NULL,
patch_version INT UNSIGNED NOT NULL,
release_notes TEXT DEFAULT NULL,
download_url VARCHAR(512) DEFAULT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY product_version (product_id, version),
KEY product_id (product_id),
KEY major_version (major_version),
KEY is_active (is_active)
) {$charsetCollate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sqlLicenses);
dbDelta($sqlVersions);
}
/**
* Create Twig cache directory
*/
private static function createCacheDir(): void
{
$cacheDir = WP_CONTENT_DIR . '/cache/wc-licensed-product/twig';
if (!file_exists($cacheDir)) {
wp_mkdir_p($cacheDir);
}
}
/**
* Get licenses table name
*/
public static function getLicensesTable(): string
{
global $wpdb;
return $wpdb->prefix . self::TABLE_LICENSES;
}
/**
* Get product versions table name
*/
public static function getVersionsTable(): string
{
global $wpdb;
return $wpdb->prefix . self::TABLE_PRODUCT_VERSIONS;
}
}

178
src/License/License.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
/**
* License Model
*
* @package Jeremias\WcLicensedProduct\License
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\License;
/**
* License entity model
*/
class License
{
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
public const STATUS_EXPIRED = 'expired';
public const STATUS_REVOKED = 'revoked';
private int $id;
private string $licenseKey;
private int $orderId;
private int $productId;
private int $customerId;
private string $domain;
private ?int $versionId;
private string $status;
private int $activationsCount;
private int $maxActivations;
private ?\DateTimeInterface $expiresAt;
private \DateTimeInterface $createdAt;
private \DateTimeInterface $updatedAt;
/**
* Create license from database row
*/
public static function fromArray(array $data): self
{
$license = new self();
$license->id = (int) $data['id'];
$license->licenseKey = $data['license_key'];
$license->orderId = (int) $data['order_id'];
$license->productId = (int) $data['product_id'];
$license->customerId = (int) $data['customer_id'];
$license->domain = $data['domain'];
$license->versionId = $data['version_id'] ? (int) $data['version_id'] : null;
$license->status = $data['status'];
$license->activationsCount = (int) $data['activations_count'];
$license->maxActivations = (int) $data['max_activations'];
$license->expiresAt = $data['expires_at'] ? new \DateTimeImmutable($data['expires_at']) : null;
$license->createdAt = new \DateTimeImmutable($data['created_at']);
$license->updatedAt = new \DateTimeImmutable($data['updated_at']);
return $license;
}
public function getId(): int
{
return $this->id;
}
public function getLicenseKey(): string
{
return $this->licenseKey;
}
public function getOrderId(): int
{
return $this->orderId;
}
public function getProductId(): int
{
return $this->productId;
}
public function getCustomerId(): int
{
return $this->customerId;
}
public function getDomain(): string
{
return $this->domain;
}
public function getVersionId(): ?int
{
return $this->versionId;
}
public function getStatus(): string
{
return $this->status;
}
public function getActivationsCount(): int
{
return $this->activationsCount;
}
public function getMaxActivations(): int
{
return $this->maxActivations;
}
public function getExpiresAt(): ?\DateTimeInterface
{
return $this->expiresAt;
}
public function getCreatedAt(): \DateTimeInterface
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeInterface
{
return $this->updatedAt;
}
/**
* Check if license is currently valid
*/
public function isValid(): bool
{
if ($this->status !== self::STATUS_ACTIVE) {
return false;
}
if ($this->expiresAt !== null && $this->expiresAt < new \DateTimeImmutable()) {
return false;
}
return true;
}
/**
* Check if license has expired
*/
public function isExpired(): bool
{
return $this->expiresAt !== null && $this->expiresAt < new \DateTimeImmutable();
}
/**
* Check if license can be activated on another domain
*/
public function canActivate(): bool
{
return $this->isValid() && $this->activationsCount < $this->maxActivations;
}
/**
* Convert to array for JSON/API responses
*/
public function toArray(): array
{
return [
'id' => $this->id,
'license_key' => $this->licenseKey,
'order_id' => $this->orderId,
'product_id' => $this->productId,
'customer_id' => $this->customerId,
'domain' => $this->domain,
'version_id' => $this->versionId,
'status' => $this->status,
'activations_count' => $this->activationsCount,
'max_activations' => $this->maxActivations,
'expires_at' => $this->expiresAt?->format('Y-m-d H:i:s'),
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
'updated_at' => $this->updatedAt->format('Y-m-d H:i:s'),
'is_valid' => $this->isValid(),
];
}
}

View File

@@ -0,0 +1,363 @@
<?php
/**
* License Manager
*
* @package Jeremias\WcLicensedProduct\License
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\License;
use Jeremias\WcLicensedProduct\Installer;
use Jeremias\WcLicensedProduct\Product\LicensedProduct;
/**
* Manages license operations (CRUD, validation, generation)
*/
class LicenseManager
{
/**
* Generate a unique license key
*/
public function generateLicenseKey(): string
{
// Format: XXXX-XXXX-XXXX-XXXX (32 chars hex, 4 groups)
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$key = '';
for ($i = 0; $i < 4; $i++) {
if ($i > 0) {
$key .= '-';
}
for ($j = 0; $j < 4; $j++) {
$key .= $chars[random_int(0, strlen($chars) - 1)];
}
}
return $key;
}
/**
* Generate a license for a completed order
*/
public function generateLicense(
int $orderId,
int $productId,
int $customerId,
string $domain
): ?License {
global $wpdb;
// Check if license already exists for this order and product
$existing = $this->getLicenseByOrderAndProduct($orderId, $productId);
if ($existing) {
return $existing;
}
$product = wc_get_product($productId);
if (!$product instanceof LicensedProduct) {
return null;
}
// Generate unique license key
$licenseKey = $this->generateLicenseKey();
while ($this->getLicenseByKey($licenseKey)) {
$licenseKey = $this->generateLicenseKey();
}
// Calculate expiration date
$expiresAt = null;
$validityDays = $product->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
$versionId = null;
if ($product->is_bound_to_version()) {
$versionId = $this->getCurrentVersionId($productId);
}
$tableName = Installer::getLicensesTable();
$result = $wpdb->insert(
$tableName,
[
'license_key' => $licenseKey,
'order_id' => $orderId,
'product_id' => $productId,
'customer_id' => $customerId,
'domain' => $this->normalizeDomain($domain),
'version_id' => $versionId,
'status' => License::STATUS_ACTIVE,
'activations_count' => 1,
'max_activations' => $product->get_max_activations(),
'expires_at' => $expiresAt,
],
['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s']
);
if ($result === false) {
return null;
}
return $this->getLicenseById((int) $wpdb->insert_id);
}
/**
* Get license by ID
*/
public function getLicenseById(int $id): ?License
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$tableName} WHERE id = %d", $id),
ARRAY_A
);
return $row ? License::fromArray($row) : null;
}
/**
* Get license by license key
*/
public function getLicenseByKey(string $licenseKey): ?License
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$tableName} WHERE license_key = %s", $licenseKey),
ARRAY_A
);
return $row ? License::fromArray($row) : null;
}
/**
* Get license by order and product
*/
public function getLicenseByOrderAndProduct(int $orderId, int $productId): ?License
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d",
$orderId,
$productId
),
ARRAY_A
);
return $row ? License::fromArray($row) : null;
}
/**
* Get all licenses for a customer
*/
public function getLicensesByCustomer(int $customerId): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE customer_id = %d ORDER BY created_at DESC",
$customerId
),
ARRAY_A
);
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
}
/**
* Get all licenses (for admin)
*/
public function getAllLicenses(int $page = 1, int $perPage = 20): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$offset = ($page - 1) * $perPage;
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$tableName} ORDER BY created_at DESC LIMIT %d OFFSET %d",
$perPage,
$offset
),
ARRAY_A
);
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
}
/**
* Get total license count
*/
public function getLicenseCount(): int
{
global $wpdb;
$tableName = Installer::getLicensesTable();
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tableName}");
}
/**
* Validate a license key for a domain
*/
public function validateLicense(string $licenseKey, string $domain): array
{
$license = $this->getLicenseByKey($licenseKey);
if (!$license) {
return [
'valid' => false,
'error' => 'license_not_found',
'message' => __('License key not found.', 'wc-licensed-product'),
];
}
// Check license status
if ($license->getStatus() === License::STATUS_REVOKED) {
return [
'valid' => false,
'error' => 'license_revoked',
'message' => __('This license has been revoked.', 'wc-licensed-product'),
];
}
// Check expiration
if ($license->isExpired()) {
$this->updateLicenseStatus($license->getId(), License::STATUS_EXPIRED);
return [
'valid' => false,
'error' => 'license_expired',
'message' => __('This license has expired.', 'wc-licensed-product'),
];
}
if ($license->getStatus() === License::STATUS_INACTIVE) {
return [
'valid' => false,
'error' => 'license_inactive',
'message' => __('This license is inactive.', 'wc-licensed-product'),
];
}
// Check domain
$normalizedDomain = $this->normalizeDomain($domain);
if ($license->getDomain() !== $normalizedDomain) {
return [
'valid' => false,
'error' => 'domain_mismatch',
'message' => __('This license is not valid for this domain.', 'wc-licensed-product'),
];
}
return [
'valid' => true,
'license' => [
'product_id' => $license->getProductId(),
'expires_at' => $license->getExpiresAt()?->format('Y-m-d'),
'version_id' => $license->getVersionId(),
],
];
}
/**
* Update license status
*/
public function updateLicenseStatus(int $licenseId, string $status): bool
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$result = $wpdb->update(
$tableName,
['status' => $status],
['id' => $licenseId],
['%s'],
['%d']
);
return $result !== false;
}
/**
* Update license domain
*/
public function updateLicenseDomain(int $licenseId, string $domain): bool
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$result = $wpdb->update(
$tableName,
['domain' => $this->normalizeDomain($domain)],
['id' => $licenseId],
['%s'],
['%d']
);
return $result !== false;
}
/**
* Delete a license
*/
public function deleteLicense(int $licenseId): bool
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$result = $wpdb->delete(
$tableName,
['id' => $licenseId],
['%d']
);
return $result !== false;
}
/**
* Normalize domain name
*/
public function normalizeDomain(string $domain): string
{
// Remove protocol
$domain = preg_replace('#^https?://#', '', $domain);
// Remove trailing slash and path
$domain = preg_replace('#/.*$#', '', $domain);
// Remove www prefix
$domain = preg_replace('#^www\.#', '', $domain);
// Lowercase
return strtolower(trim($domain));
}
/**
* Get current version ID for a product
*/
private function getCurrentVersionId(int $productId): ?int
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$versionId = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$tableName} WHERE product_id = %d AND is_active = 1 ORDER BY released_at DESC LIMIT 1",
$productId
)
);
return $versionId ? (int) $versionId : null;
}
}

163
src/Plugin.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
/**
* Main Plugin class
*
* @package Jeremias\WcLicensedProduct
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct;
use Jeremias\WcLicensedProduct\Admin\AdminController;
use Jeremias\WcLicensedProduct\Api\RestApiController;
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
use Jeremias\WcLicensedProduct\Frontend\AccountController;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* Main plugin controller
*/
final class Plugin
{
/**
* Singleton instance
*/
private static ?Plugin $instance = null;
/**
* Twig environment
*/
private Environment $twig;
/**
* License manager
*/
private LicenseManager $licenseManager;
/**
* Get singleton instance
*/
public static function instance(): Plugin
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Private constructor for singleton
*/
private function __construct()
{
$this->initTwig();
$this->initComponents();
$this->registerHooks();
}
/**
* Initialize Twig environment
*/
private function initTwig(): void
{
$loader = new FilesystemLoader(WC_LICENSED_PRODUCT_PLUGIN_DIR . 'templates');
$this->twig = new Environment($loader, [
'cache' => WP_CONTENT_DIR . '/cache/wc-licensed-product/twig',
'auto_reload' => WP_DEBUG,
]);
// Add WordPress functions as Twig functions
$this->twig->addFunction(new \Twig\TwigFunction('__', function (string $text, string $domain = 'wc-licensed-product'): string {
return __($text, $domain);
}));
$this->twig->addFunction(new \Twig\TwigFunction('esc_html', 'esc_html'));
$this->twig->addFunction(new \Twig\TwigFunction('esc_attr', 'esc_attr'));
$this->twig->addFunction(new \Twig\TwigFunction('esc_url', 'esc_url'));
$this->twig->addFunction(new \Twig\TwigFunction('wp_nonce_field', 'wp_nonce_field', ['is_safe' => ['html']]));
$this->twig->addFunction(new \Twig\TwigFunction('admin_url', 'admin_url'));
$this->twig->addFunction(new \Twig\TwigFunction('wc_get_endpoint_url', 'wc_get_endpoint_url'));
}
/**
* Initialize plugin components
*/
private function initComponents(): void
{
$this->licenseManager = new LicenseManager();
// Initialize controllers
new LicensedProductType();
new CheckoutController($this->licenseManager);
new AccountController($this->twig, $this->licenseManager);
new RestApiController($this->licenseManager);
if (is_admin()) {
new AdminController($this->twig, $this->licenseManager);
}
}
/**
* Register plugin hooks
*/
private function registerHooks(): void
{
// Generate license on order completion
add_action('woocommerce_order_status_completed', [$this, 'onOrderCompleted']);
}
/**
* Handle order completion - generate licenses
*/
public function onOrderCompleted(int $orderId): void
{
$order = wc_get_order($orderId);
if (!$order) {
return;
}
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && $product->is_type('licensed')) {
$domain = $order->get_meta('_licensed_product_domain');
if ($domain) {
$this->licenseManager->generateLicense(
$orderId,
$product->get_id(),
$order->get_customer_id(),
$domain
);
}
}
}
}
/**
* Get Twig environment
*/
public function getTwig(): Environment
{
return $this->twig;
}
/**
* Get license manager
*/
public function getLicenseManager(): LicenseManager
{
return $this->licenseManager;
}
/**
* Render a Twig template
*/
public function render(string $template, array $context = []): string
{
return $this->twig->render($template, $context);
}
}

View File

@@ -0,0 +1,103 @@
<?php
/**
* Licensed Product Class
*
* @package Jeremias\WcLicensedProduct\Product
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product;
use WC_Product;
/**
* Licensed Product type extending WooCommerce Product
*/
class LicensedProduct extends WC_Product
{
/**
* Product type
*/
protected $product_type = 'licensed';
/**
* Constructor
*/
public function __construct($product = 0)
{
parent::__construct($product);
}
/**
* Get product type
*/
public function get_type(): string
{
return 'licensed';
}
/**
* 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
*/
public function get_max_activations(): int
{
$value = $this->get_meta('_licensed_max_activations', true);
return $value ? (int) $value : 1;
}
/**
* Get validity days
*/
public function get_validity_days(): ?int
{
$value = $this->get_meta('_licensed_validity_days', true);
return $value !== '' ? (int) $value : null;
}
/**
* Check if license should be bound to major version
*/
public function is_bound_to_version(): bool
{
return $this->get_meta('_licensed_bind_to_version', true) === 'yes';
}
/**
* Get current software version
*/
public function get_current_version(): string
{
return $this->get_meta('_licensed_current_version', true) ?: '';
}
/**
* Get major version number from version string
*/
public function get_major_version(): int
{
$version = $this->get_current_version();
if (empty($version)) {
return 1;
}
$parts = explode('.', $version);
return (int) ($parts[0] ?? 1);
}
}

View File

@@ -0,0 +1,200 @@
<?php
/**
* Licensed Product Type
*
* @package Jeremias\WcLicensedProduct\Product
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product;
/**
* Registers and handles the Licensed product type for WooCommerce
*/
final class LicensedProductType
{
/**
* Constructor
*/
public function __construct()
{
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
// Register product type
add_filter('product_type_selector', [$this, 'addProductType']);
add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 2);
// Add product data tabs
add_filter('woocommerce_product_data_tabs', [$this, 'addProductDataTab']);
add_action('woocommerce_product_data_panels', [$this, 'addProductDataPanel']);
// Save product meta
add_action('woocommerce_process_product_meta_licensed', [$this, 'saveProductMeta']);
// Show price and add to cart for licensed products
add_action('woocommerce_licensed_add_to_cart', [$this, 'addToCartTemplate']);
// Make product virtual by default
add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2);
}
/**
* Add product type to selector
*/
public function addProductType(array $types): array
{
$types['licensed'] = __('Licensed Product', 'wc-licensed-product');
return $types;
}
/**
* Get product class for licensed type
*/
public function getProductClass(string $className, string $productType): string
{
if ($productType === 'licensed') {
return LicensedProduct::class;
}
return $className;
}
/**
* Add product data tab for license settings
*/
public function addProductDataTab(array $tabs): array
{
$tabs['licensed_product'] = [
'label' => __('License Settings', 'wc-licensed-product'),
'target' => 'licensed_product_data',
'class' => ['show_if_licensed'],
'priority' => 21,
];
return $tabs;
}
/**
* Add product data panel content
*/
public function addProductDataPanel(): void
{
global $post;
?>
<div id="licensed_product_data" class="panel woocommerce_options_panel hidden">
<div class="options_group">
<?php
woocommerce_wp_text_input([
'id' => '_licensed_max_activations',
'label' => __('Max Activations', 'wc-licensed-product'),
'description' => __('Maximum number of domain activations per license. Default: 1', 'wc-licensed-product'),
'desc_tip' => true,
'type' => 'number',
'custom_attributes' => [
'min' => '1',
'step' => '1',
],
'value' => get_post_meta($post->ID, '_licensed_max_activations', true) ?: '1',
]);
woocommerce_wp_text_input([
'id' => '_licensed_validity_days',
'label' => __('License Validity (Days)', 'wc-licensed-product'),
'description' => __('Number of days the license is valid. Leave empty for lifetime license.', 'wc-licensed-product'),
'desc_tip' => true,
'type' => 'number',
'custom_attributes' => [
'min' => '0',
'step' => '1',
],
]);
woocommerce_wp_checkbox([
'id' => '_licensed_bind_to_version',
'label' => __('Bind to Major Version', 'wc-licensed-product'),
'description' => __('If enabled, licenses are bound to the major version at purchase time.', 'wc-licensed-product'),
]);
woocommerce_wp_text_input([
'id' => '_licensed_current_version',
'label' => __('Current Version', 'wc-licensed-product'),
'description' => __('Current software version (e.g., 1.0.0)', 'wc-licensed-product'),
'desc_tip' => true,
'placeholder' => '1.0.0',
]);
?>
</div>
</div>
<script type="text/javascript">
jQuery(document).ready(function($) {
// Show/hide panels based on product type
$('select#product-type').change(function() {
if ($(this).val() === 'licensed') {
$('.show_if_licensed').show();
$('.general_options').show();
$('.pricing').show();
} else {
$('.show_if_licensed').hide();
}
}).change();
// Show general tab for licensed products
$('#product-type').on('change', function() {
if ($(this).val() === 'licensed') {
$('.general_tab').show();
}
});
});
</script>
<?php
}
/**
* Save product meta
*/
public function saveProductMeta(int $postId): void
{
// Verify nonce is handled by WooCommerce
$maxActivations = isset($_POST['_licensed_max_activations'])
? absint($_POST['_licensed_max_activations'])
: 1;
update_post_meta($postId, '_licensed_max_activations', $maxActivations);
$validityDays = isset($_POST['_licensed_validity_days']) && $_POST['_licensed_validity_days'] !== ''
? absint($_POST['_licensed_validity_days'])
: '';
update_post_meta($postId, '_licensed_validity_days', $validityDays);
$bindToVersion = isset($_POST['_licensed_bind_to_version']) ? 'yes' : 'no';
update_post_meta($postId, '_licensed_bind_to_version', $bindToVersion);
$currentVersion = isset($_POST['_licensed_current_version'])
? sanitize_text_field($_POST['_licensed_current_version'])
: '';
update_post_meta($postId, '_licensed_current_version', $currentVersion);
}
/**
* Add to cart template for licensed products
*/
public function addToCartTemplate(): void
{
wc_get_template('single-product/add-to-cart/simple.php');
}
/**
* Make licensed products virtual by default
*/
public function isVirtual(bool $isVirtual, \WC_Product $product): bool
{
if ($product->is_type('licensed')) {
return true;
}
return $isVirtual;
}
}