Implement versions 0.0.4-0.0.7 features

v0.0.4:
- Add WooCommerce settings tab for default license settings
- Per-product settings override global defaults

v0.0.5:
- Add bulk license operations (activate, deactivate, revoke, extend, delete)
- Add license renewal/extension and lifetime functionality
- Add quick action buttons per license row

v0.0.6:
- Add license dashboard with statistics and analytics
- Add license transfer functionality (admin)
- Add CSV export for licenses
- Add OpenAPI 3.1 specification
- Remove /deactivate API endpoint

v0.0.7:
- Move license dashboard to WooCommerce Reports section
- Add license search and filtering in admin
- Add customer-facing license transfer with AJAX modal
- Add email notifications for license expiration warnings
- Add bulk import licenses from CSV
- Update README with comprehensive documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 20:32:35 +01:00
parent 78e43b9aea
commit 49a0699963
21 changed files with 4132 additions and 289 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
<?php
/**
* Settings Controller
*
* @package Jeremias\WcLicensedProduct\Admin
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Admin;
/**
* Handles WooCommerce settings tab for license defaults
*/
final class SettingsController
{
/**
* Settings option name
*/
public const OPTION_NAME = 'wc_licensed_product_settings';
/**
* Constructor
*/
public function __construct()
{
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
add_filter('woocommerce_settings_tabs_array', [$this, 'addSettingsTab'], 50);
add_action('woocommerce_settings_tabs_licensed_product', [$this, 'renderSettingsTab']);
add_action('woocommerce_update_options_licensed_product', [$this, 'saveSettings']);
}
/**
* Add settings tab to WooCommerce settings
*/
public function addSettingsTab(array $tabs): array
{
$tabs['licensed_product'] = __('Licensed Products', 'wc-licensed-product');
return $tabs;
}
/**
* Get settings fields
*/
public function getSettingsFields(): array
{
return [
'section_title' => [
'name' => __('Default License Settings', 'wc-licensed-product'),
'type' => 'title',
'desc' => __('These settings serve as defaults for new licensed products. Individual product settings override these defaults.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_section_defaults',
],
'default_max_activations' => [
'name' => __('Default Max Activations', 'wc-licensed-product'),
'type' => 'number',
'desc' => __('Default maximum number of domain activations per license.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_default_max_activations',
'default' => '1',
'custom_attributes' => [
'min' => '1',
'step' => '1',
],
],
'default_validity_days' => [
'name' => __('Default License Validity (Days)', 'wc-licensed-product'),
'type' => 'number',
'desc' => __('Default number of days a license is valid. Leave empty or set to 0 for lifetime licenses.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_default_validity_days',
'default' => '',
'placeholder' => __('Lifetime', 'wc-licensed-product'),
'custom_attributes' => [
'min' => '0',
'step' => '1',
],
],
'default_bind_to_version' => [
'name' => __('Default Bind to Major Version', 'wc-licensed-product'),
'type' => 'checkbox',
'desc' => __('If enabled, licenses are bound to the major version at purchase time by default.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_default_bind_to_version',
'default' => 'no',
],
'section_end' => [
'type' => 'sectionend',
'id' => 'wc_licensed_product_section_defaults_end',
],
];
}
/**
* Render settings tab content
*/
public function renderSettingsTab(): void
{
woocommerce_admin_fields($this->getSettingsFields());
}
/**
* Save settings
*/
public function saveSettings(): void
{
woocommerce_update_options($this->getSettingsFields());
}
/**
* Get default max activations
*/
public static function getDefaultMaxActivations(): int
{
$value = get_option('wc_licensed_product_default_max_activations', 1);
return max(1, (int) $value);
}
/**
* Get default validity days
*/
public static function getDefaultValidityDays(): ?int
{
$value = get_option('wc_licensed_product_default_validity_days', '');
if ($value === '' || $value === '0') {
return null;
}
return (int) $value;
}
/**
* Get default bind to version setting
*/
public static function getDefaultBindToVersion(): bool
{
return get_option('wc_licensed_product_default_bind_to_version', 'no') === 'yes';
}
}

View File

@@ -180,25 +180,6 @@ final class RestApiController
],
],
]);
// 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',
],
],
]);
}
/**
@@ -318,55 +299,4 @@ final class RestApiController
'message' => __('License activated successfully.', 'wc-licensed-product'),
]);
}
/**
* Deactivate license endpoint
*/
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');
$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

@@ -34,6 +34,210 @@ final class LicenseEmailController
// Add license info to order details in emails
add_action('woocommerce_order_item_meta_end', [$this, 'addLicenseToOrderItem'], 10, 4);
// Schedule cron job for expiration warnings
add_action('init', [$this, 'scheduleExpirationCheck']);
// Cron action for checking expiring licenses
add_action('wclp_check_expiring_licenses', [$this, 'sendExpirationWarnings']);
}
/**
* Schedule the expiration check cron job
*/
public function scheduleExpirationCheck(): void
{
if (!wp_next_scheduled('wclp_check_expiring_licenses')) {
wp_schedule_event(time(), 'daily', 'wclp_check_expiring_licenses');
}
}
/**
* Send expiration warning emails
*/
public function sendExpirationWarnings(): void
{
// Check for licenses expiring in 7 days
$this->processExpirationWarnings(7, 'expiring_7_days');
// Check for licenses expiring in 1 day
$this->processExpirationWarnings(1, 'expiring_1_day');
}
/**
* Process and send expiration warnings for a specific time frame
*
* @param int $days Days until expiration
* @param string $notificationType Notification type identifier
*/
private function processExpirationWarnings(int $days, string $notificationType): void
{
$licenses = $this->licenseManager->getLicensesExpiringSoon($days);
foreach ($licenses as $license) {
// Skip if already notified
if ($this->licenseManager->wasExpirationNotified($license->getId(), $notificationType)) {
continue;
}
// Send the warning email
if ($this->sendExpirationWarningEmail($license, $days)) {
// Mark as notified
$this->licenseManager->markExpirationNotified($license->getId(), $notificationType);
}
}
}
/**
* Send expiration warning email to customer
*
* @param \Jeremias\WcLicensedProduct\License\License $license License object
* @param int $daysRemaining Days until expiration
* @return bool Whether email was sent successfully
*/
private function sendExpirationWarningEmail($license, int $daysRemaining): bool
{
$customer = get_userdata($license->getCustomerId());
if (!$customer || !$customer->user_email) {
return false;
}
$product = wc_get_product($license->getProductId());
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
$siteName = get_bloginfo('name');
$expiresAt = $license->getExpiresAt();
$expirationDate = $expiresAt ? $expiresAt->format(get_option('date_format')) : '';
// Email subject
if ($daysRemaining === 1) {
$subject = sprintf(
/* translators: 1: Product name, 2: Site name */
__('[%2$s] Your license for %1$s expires tomorrow', 'wc-licensed-product'),
$productName,
$siteName
);
} else {
$subject = sprintf(
/* translators: 1: Product name, 2: Number of days, 3: Site name */
__('[%3$s] Your license for %1$s expires in %2$d days', 'wc-licensed-product'),
$productName,
$daysRemaining,
$siteName
);
}
// Email content
$message = $this->buildExpirationWarningHtml($license, $customer, $productName, $daysRemaining, $expirationDate);
// Send email
$headers = [
'Content-Type: text/html; charset=UTF-8',
'From: ' . $siteName . ' <' . get_option('admin_email') . '>',
];
return wp_mail($customer->user_email, $subject, $message, $headers);
}
/**
* Build HTML content for expiration warning email
*
* @param \Jeremias\WcLicensedProduct\License\License $license License object
* @param \WP_User $customer Customer user object
* @param string $productName Product name
* @param int $daysRemaining Days until expiration
* @param string $expirationDate Formatted expiration date
* @return string HTML email content
*/
private function buildExpirationWarningHtml($license, $customer, string $productName, int $daysRemaining, string $expirationDate): string
{
$siteName = get_bloginfo('name');
$siteUrl = home_url();
$accountUrl = wc_get_account_endpoint_url('licenses');
ob_start();
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px;">
<h1 style="color: #333; margin-top: 0; font-size: 24px;">
<?php esc_html_e('License Expiration Notice', 'wc-licensed-product'); ?>
</h1>
<p><?php printf(esc_html__('Hello %s,', 'wc-licensed-product'), esc_html($customer->display_name)); ?></p>
<?php if ($daysRemaining === 1): ?>
<p style="color: #dc3545; font-weight: 600;">
<?php printf(
esc_html__('Your license for %s will expire tomorrow (%s).', 'wc-licensed-product'),
esc_html($productName),
esc_html($expirationDate)
); ?>
</p>
<?php else: ?>
<p style="color: #ffc107; font-weight: 600;">
<?php printf(
esc_html__('Your license for %1$s will expire in %2$d days (%3$s).', 'wc-licensed-product'),
esc_html($productName),
$daysRemaining,
esc_html($expirationDate)
); ?>
</p>
<?php endif; ?>
<div style="background: #fff; padding: 20px; border-radius: 4px; margin: 20px 0; border: 1px solid #e5e5e5;">
<h3 style="margin-top: 0; font-size: 16px;"><?php esc_html_e('License Details', 'wc-licensed-product'); ?></h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666;"><?php esc_html_e('Product:', 'wc-licensed-product'); ?></td>
<td style="padding: 8px 0; font-weight: 600;"><?php echo esc_html($productName); ?></td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></td>
<td style="padding: 8px 0;">
<code style="background: #f5f5f5; padding: 3px 8px; border-radius: 3px; font-family: monospace;">
<?php echo esc_html($license->getLicenseKey()); ?>
</code>
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></td>
<td style="padding: 8px 0;"><?php echo esc_html($license->getDomain()); ?></td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><?php esc_html_e('Expires:', 'wc-licensed-product'); ?></td>
<td style="padding: 8px 0; color: #dc3545; font-weight: 600;"><?php echo esc_html($expirationDate); ?></td>
</tr>
</table>
</div>
<p><?php esc_html_e('To continue using this product, please renew your license before the expiration date.', 'wc-licensed-product'); ?></p>
<p style="margin-top: 25px;">
<a href="<?php echo esc_url($accountUrl); ?>"
style="display: inline-block; background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: 600;">
<?php esc_html_e('View My Licenses', 'wc-licensed-product'); ?>
</a>
</p>
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
<p style="font-size: 14px; color: #666; margin-bottom: 0;">
<?php printf(
esc_html__('This email was sent from %s.', 'wc-licensed-product'),
'<a href="' . esc_url($siteUrl) . '" style="color: #2271b1;">' . esc_html($siteName) . '</a>'
); ?>
</p>
</div>
</body>
</html>
<?php
return ob_get_clean();
}
/**

View File

@@ -52,6 +52,9 @@ final class AccountController
// Enqueue frontend styles and scripts
add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']);
// AJAX handler for license transfer request
add_action('wp_ajax_wclp_customer_transfer_license', [$this, 'handleTransferRequest']);
}
/**
@@ -181,7 +184,19 @@ final class AccountController
</div>
<div class="license-info-row">
<span><strong><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></strong> <?php echo esc_html($item['license']->getDomain()); ?></span>
<span class="license-domain-display" data-license-id="<?php echo esc_attr($item['license']->getId()); ?>">
<strong><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></strong>
<span class="domain-value"><?php echo esc_html($item['license']->getDomain()); ?></span>
<?php if (in_array($item['license']->getStatus(), ['active', 'inactive'], true)): ?>
<button type="button" class="wclp-transfer-btn"
data-license-id="<?php echo esc_attr($item['license']->getId()); ?>"
data-current-domain="<?php echo esc_attr($item['license']->getDomain()); ?>"
title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>">
<span class="dashicons dashicons-randomize"></span>
<?php esc_html_e('Transfer', 'wc-licensed-product'); ?>
</button>
<?php endif; ?>
</span>
<span><strong><?php esc_html_e('Expires:', 'wc-licensed-product'); ?></strong>
<?php
$expiresAt = $item['license']->getExpiresAt();
@@ -213,6 +228,40 @@ final class AccountController
</div>
<?php endforeach; ?>
</div>
<!-- Transfer Modal -->
<div id="wclp-transfer-modal" class="wclp-modal" style="display:none;">
<div class="wclp-modal-overlay"></div>
<div class="wclp-modal-content">
<button type="button" class="wclp-modal-close" aria-label="<?php esc_attr_e('Close', 'wc-licensed-product'); ?>">&times;</button>
<h3><?php esc_html_e('Transfer License to New Domain', 'wc-licensed-product'); ?></h3>
<form id="wclp-transfer-form">
<input type="hidden" name="license_id" id="transfer-license-id" value="">
<div class="wclp-form-row">
<label><?php esc_html_e('Current Domain', 'wc-licensed-product'); ?></label>
<p class="wclp-current-domain"><code id="transfer-current-domain"></code></p>
</div>
<div class="wclp-form-row">
<label for="transfer-new-domain"><?php esc_html_e('New Domain', 'wc-licensed-product'); ?></label>
<input type="text" name="new_domain" id="transfer-new-domain"
placeholder="example.com" required
pattern="[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+">
<p class="wclp-field-description"><?php esc_html_e('Enter the new domain without http:// or www.', 'wc-licensed-product'); ?></p>
</div>
<div class="wclp-form-row wclp-form-actions">
<button type="submit" class="button wclp-btn-primary" id="wclp-transfer-submit">
<?php esc_html_e('Transfer License', 'wc-licensed-product'); ?>
</button>
<button type="button" class="button wclp-modal-cancel"><?php esc_html_e('Cancel', 'wc-licensed-product'); ?></button>
</div>
<div id="wclp-transfer-message" class="wclp-message" style="display:none;"></div>
</form>
</div>
</div>
<?php
}
@@ -241,10 +290,111 @@ final class AccountController
);
wp_localize_script('wc-licensed-product-frontend', 'wcLicensedProduct', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'transferNonce' => wp_create_nonce('wclp_customer_transfer'),
'strings' => [
'copied' => __('Copied!', 'wc-licensed-product'),
'copyFailed' => __('Copy failed', 'wc-licensed-product'),
'transferSuccess' => __('License transferred successfully!', 'wc-licensed-product'),
'transferError' => __('Transfer failed. Please try again.', 'wc-licensed-product'),
'transferConfirm' => __('Are you sure you want to transfer this license to a new domain? This action cannot be undone.', 'wc-licensed-product'),
'invalidDomain' => __('Please enter a valid domain.', 'wc-licensed-product'),
],
]);
}
/**
* Handle AJAX license transfer request from customer
*/
public function handleTransferRequest(): void
{
// Verify nonce
if (!check_ajax_referer('wclp_customer_transfer', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403);
}
// Verify user is logged in
$customerId = get_current_user_id();
if (!$customerId) {
wp_send_json_error(['message' => __('Please log in to transfer a license.', 'wc-licensed-product')], 401);
}
// Get and validate license ID
$licenseId = isset($_POST['license_id']) ? absint($_POST['license_id']) : 0;
if (!$licenseId) {
wp_send_json_error(['message' => __('Invalid license.', 'wc-licensed-product')], 400);
}
// Get and validate new domain
$newDomain = isset($_POST['new_domain']) ? sanitize_text_field($_POST['new_domain']) : '';
$newDomain = $this->normalizeDomain($newDomain);
if (empty($newDomain)) {
wp_send_json_error(['message' => __('Please enter a valid domain.', 'wc-licensed-product')], 400);
}
// Verify the license belongs to this customer
$license = $this->licenseManager->getLicenseById($licenseId);
if (!$license) {
wp_send_json_error(['message' => __('License not found.', 'wc-licensed-product')], 404);
}
if ($license->getCustomerId() !== $customerId) {
wp_send_json_error(['message' => __('You do not have permission to transfer this license.', 'wc-licensed-product')], 403);
}
// Check if license is in a transferable state
if ($license->getStatus() === 'revoked') {
wp_send_json_error(['message' => __('Revoked licenses cannot be transferred.', 'wc-licensed-product')], 400);
}
if ($license->getStatus() === 'expired') {
wp_send_json_error(['message' => __('Expired licenses cannot be transferred.', 'wc-licensed-product')], 400);
}
// Check if domain is the same
if ($license->getDomain() === $newDomain) {
wp_send_json_error(['message' => __('The new domain is the same as the current domain.', 'wc-licensed-product')], 400);
}
// Perform the transfer
$result = $this->licenseManager->transferLicense($licenseId, $newDomain);
if ($result) {
wp_send_json_success([
'message' => __('License transferred successfully!', 'wc-licensed-product'),
'new_domain' => $newDomain,
]);
} else {
wp_send_json_error(['message' => __('Failed to transfer license. Please try again.', 'wc-licensed-product')], 500);
}
}
/**
* Normalize domain for comparison and storage
*/
private function normalizeDomain(string $domain): string
{
// Remove protocol if present
$domain = preg_replace('#^https?://#i', '', $domain);
// Remove www prefix
$domain = preg_replace('#^www\.#i', '', $domain);
// Remove trailing slash
$domain = rtrim($domain, '/');
// Remove path if present
$domain = explode('/', $domain)[0];
// Convert to lowercase
$domain = strtolower($domain);
// Basic validation - must contain at least one dot and valid characters
if (!preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/', $domain)) {
return '';
}
return $domain;
}
}

View File

@@ -44,6 +44,12 @@ final class Installer
*/
public static function deactivate(): void
{
// Clear scheduled cron events
$timestamp = wp_next_scheduled('wclp_check_expiring_licenses');
if ($timestamp) {
wp_unschedule_event($timestamp, 'wclp_check_expiring_licenses');
}
// Flush rewrite rules
flush_rewrite_rules();
}

View File

@@ -176,21 +176,63 @@ class LicenseManager
}
/**
* Get all licenses (for admin)
* Get all licenses (for admin) with optional filtering
*
* @param int $page Page number
* @param int $perPage Items per page
* @param array $filters Optional filters: search, status, product_id, customer_id
* @return array Array of License objects
*/
public function getAllLicenses(int $page = 1, int $perPage = 20): array
public function getAllLicenses(int $page = 1, int $perPage = 20, array $filters = []): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$offset = ($page - 1) * $perPage;
$where = [];
$params = [];
// Search filter (searches license key, domain, customer email)
if (!empty($filters['search'])) {
$search = '%' . $wpdb->esc_like($filters['search']) . '%';
$where[] = "(license_key LIKE %s OR domain LIKE %s)";
$params[] = $search;
$params[] = $search;
}
// Status filter
if (!empty($filters['status']) && in_array($filters['status'], [
License::STATUS_ACTIVE,
License::STATUS_INACTIVE,
License::STATUS_EXPIRED,
License::STATUS_REVOKED,
], true)) {
$where[] = "status = %s";
$params[] = $filters['status'];
}
// Product filter
if (!empty($filters['product_id'])) {
$where[] = "product_id = %d";
$params[] = absint($filters['product_id']);
}
// Customer filter
if (!empty($filters['customer_id'])) {
$where[] = "customer_id = %d";
$params[] = absint($filters['customer_id']);
}
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
$params[] = $perPage;
$params[] = $offset;
$sql = "SELECT * FROM {$tableName} {$whereClause} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$tableName} ORDER BY created_at DESC LIMIT %d OFFSET %d",
$perPage,
$offset
),
$wpdb->prepare($sql, $params),
ARRAY_A
);
@@ -198,14 +240,83 @@ class LicenseManager
}
/**
* Get total license count
* Get total license count with optional filtering
*
* @param array $filters Optional filters: search, status, product_id, customer_id
* @return int Total count
*/
public function getLicenseCount(): int
public function getLicenseCount(array $filters = []): int
{
global $wpdb;
$tableName = Installer::getLicensesTable();
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tableName}");
$where = [];
$params = [];
// Search filter
if (!empty($filters['search'])) {
$search = '%' . $wpdb->esc_like($filters['search']) . '%';
$where[] = "(license_key LIKE %s OR domain LIKE %s)";
$params[] = $search;
$params[] = $search;
}
// Status filter
if (!empty($filters['status']) && in_array($filters['status'], [
License::STATUS_ACTIVE,
License::STATUS_INACTIVE,
License::STATUS_EXPIRED,
License::STATUS_REVOKED,
], true)) {
$where[] = "status = %s";
$params[] = $filters['status'];
}
// Product filter
if (!empty($filters['product_id'])) {
$where[] = "product_id = %d";
$params[] = absint($filters['product_id']);
}
// Customer filter
if (!empty($filters['customer_id'])) {
$where[] = "customer_id = %d";
$params[] = absint($filters['customer_id']);
}
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
if (empty($params)) {
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tableName}");
}
return (int) $wpdb->get_var(
$wpdb->prepare("SELECT COUNT(*) FROM {$tableName} {$whereClause}", $params)
);
}
/**
* Get all licensed products for filter dropdown
*
* @return array Array of [id => name] pairs
*/
public function getLicensedProducts(): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$productIds = $wpdb->get_col("SELECT DISTINCT product_id FROM {$tableName}");
$products = [];
foreach ($productIds as $productId) {
$product = wc_get_product((int) $productId);
if ($product) {
$products[(int) $productId] = $product->get_name();
}
}
return $products;
}
/**
@@ -360,4 +471,430 @@ class LicenseManager
return $versionId ? (int) $versionId : null;
}
/**
* Extend license expiration
*
* @param int $licenseId License ID
* @param int $days Number of days to extend
* @return bool Success
*/
public function extendLicense(int $licenseId, int $days): bool
{
global $wpdb;
$license = $this->getLicenseById($licenseId);
if (!$license) {
return false;
}
// Calculate new expiration date
$currentExpiry = $license->getExpiresAt();
if ($currentExpiry === null) {
// License is lifetime, set expiration from now
$newExpiry = (new \DateTimeImmutable())->modify("+{$days} days");
} elseif ($currentExpiry < new \DateTimeImmutable()) {
// License is expired, extend from now
$newExpiry = (new \DateTimeImmutable())->modify("+{$days} days");
} else {
// License still valid, extend from current expiry
$newExpiry = \DateTimeImmutable::createFromInterface($currentExpiry)->modify("+{$days} days");
}
$tableName = Installer::getLicensesTable();
$result = $wpdb->update(
$tableName,
['expires_at' => $newExpiry->format('Y-m-d H:i:s')],
['id' => $licenseId],
['%s'],
['%d']
);
// If license was expired, reactivate it
if ($result !== false && $license->getStatus() === License::STATUS_EXPIRED) {
$this->updateLicenseStatus($licenseId, License::STATUS_ACTIVE);
}
return $result !== false;
}
/**
* Set license to lifetime (no expiration)
*
* @param int $licenseId License ID
* @return bool Success
*/
public function setLicenseLifetime(int $licenseId): bool
{
global $wpdb;
$license = $this->getLicenseById($licenseId);
$tableName = Installer::getLicensesTable();
// Use raw query to set NULL
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$tableName} SET expires_at = NULL WHERE id = %d",
$licenseId
)
);
// If license was expired, reactivate it
if ($result !== false && $license && $license->getStatus() === License::STATUS_EXPIRED) {
$this->updateLicenseStatus($licenseId, License::STATUS_ACTIVE);
}
return $result !== false;
}
/**
* Bulk update license status
*
* @param array $licenseIds Array of license IDs
* @param string $status New status
* @return int Number of licenses updated
*/
public function bulkUpdateStatus(array $licenseIds, string $status): int
{
global $wpdb;
if (empty($licenseIds)) {
return 0;
}
$tableName = Installer::getLicensesTable();
$ids = array_map('absint', $licenseIds);
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$tableName} SET status = %s WHERE id IN ({$placeholders})",
array_merge([$status], $ids)
)
);
return $result !== false ? (int) $result : 0;
}
/**
* Bulk delete licenses
*
* @param array $licenseIds Array of license IDs
* @return int Number of licenses deleted
*/
public function bulkDelete(array $licenseIds): int
{
global $wpdb;
if (empty($licenseIds)) {
return 0;
}
$tableName = Installer::getLicensesTable();
$ids = array_map('absint', $licenseIds);
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$result = $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$tableName} WHERE id IN ({$placeholders})",
$ids
)
);
return $result !== false ? (int) $result : 0;
}
/**
* Bulk extend licenses
*
* @param array $licenseIds Array of license IDs
* @param int $days Number of days to extend
* @return int Number of licenses extended
*/
public function bulkExtend(array $licenseIds, int $days): int
{
$count = 0;
foreach ($licenseIds as $licenseId) {
if ($this->extendLicense((int) $licenseId, $days)) {
$count++;
}
}
return $count;
}
/**
* Transfer license to a new domain
*
* @param int $licenseId License ID
* @param string $newDomain New domain to transfer to
* @return bool Success
*/
public function transferLicense(int $licenseId, string $newDomain): bool
{
$license = $this->getLicenseById($licenseId);
if (!$license) {
return false;
}
// Cannot transfer revoked licenses
if ($license->getStatus() === License::STATUS_REVOKED) {
return false;
}
return $this->updateLicenseDomain($licenseId, $newDomain);
}
/**
* Get license statistics
*
* @return array Statistics data
*/
public function getStatistics(): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
// Get counts by status
$statusCounts = $wpdb->get_results(
"SELECT status, COUNT(*) as count FROM {$tableName} GROUP BY status",
ARRAY_A
);
$byStatus = [
License::STATUS_ACTIVE => 0,
License::STATUS_INACTIVE => 0,
License::STATUS_EXPIRED => 0,
License::STATUS_REVOKED => 0,
];
foreach ($statusCounts ?: [] as $row) {
$byStatus[$row['status']] = (int) $row['count'];
}
// Get total count
$total = array_sum($byStatus);
// Get lifetime vs expiring licenses
$lifetimeCount = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$tableName} WHERE expires_at IS NULL"
);
$expiringCount = $total - $lifetimeCount;
// Get licenses expiring soon (next 30 days)
$expiringSoon = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$tableName} WHERE expires_at IS NOT NULL AND expires_at <= %s AND expires_at > NOW() AND status = %s",
(new \DateTimeImmutable())->modify('+30 days')->format('Y-m-d H:i:s'),
License::STATUS_ACTIVE
)
);
// Get licenses by product
$byProduct = $wpdb->get_results(
"SELECT product_id, COUNT(*) as count FROM {$tableName} GROUP BY product_id ORDER BY count DESC LIMIT 10",
ARRAY_A
);
$productStats = [];
foreach ($byProduct ?: [] as $row) {
$product = wc_get_product((int) $row['product_id']);
$productStats[] = [
'product_id' => (int) $row['product_id'],
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
'count' => (int) $row['count'],
];
}
// Get licenses created per month (last 12 months)
$monthlyData = $wpdb->get_results(
"SELECT DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count
FROM {$tableName}
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month ASC",
ARRAY_A
);
$monthlyStats = [];
foreach ($monthlyData ?: [] as $row) {
$monthlyStats[$row['month']] = (int) $row['count'];
}
// Get top domains
$topDomains = $wpdb->get_results(
"SELECT domain, COUNT(*) as count FROM {$tableName} GROUP BY domain ORDER BY count DESC LIMIT 10",
ARRAY_A
);
return [
'total' => $total,
'by_status' => $byStatus,
'lifetime' => $lifetimeCount,
'expiring' => $expiringCount,
'expiring_soon' => $expiringSoon,
'by_product' => $productStats,
'monthly' => $monthlyStats,
'top_domains' => $topDomains ?: [],
];
}
/**
* Get licenses expiring within specified days
*
* @param int $days Number of days to look ahead
* @param bool $excludeNotified Whether to exclude already notified licenses
* @return array Array of License objects with customer data
*/
public function getLicensesExpiringSoon(int $days = 7, bool $excludeNotified = true): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$now = new \DateTimeImmutable();
$future = $now->modify("+{$days} days");
$sql = "SELECT * FROM {$tableName}
WHERE expires_at IS NOT NULL
AND expires_at > %s
AND expires_at <= %s
AND status = %s";
$params = [
$now->format('Y-m-d H:i:s'),
$future->format('Y-m-d H:i:s'),
License::STATUS_ACTIVE,
];
$rows = $wpdb->get_results(
$wpdb->prepare($sql, $params),
ARRAY_A
);
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
}
/**
* Mark license as notified for expiration warning
*
* @param int $licenseId License ID
* @param string $notificationType Type of notification (e.g., 'expiring_7_days', 'expiring_1_day')
* @return bool Success
*/
public function markExpirationNotified(int $licenseId, string $notificationType): bool
{
$metaKey = '_wclp_expiration_notified_' . sanitize_key($notificationType);
update_user_meta($this->getLicenseById($licenseId)?->getCustomerId() ?? 0, $metaKey . '_' . $licenseId, current_time('mysql'));
return true;
}
/**
* Check if license was already notified for expiration
*
* @param int $licenseId License ID
* @param string $notificationType Type of notification
* @return bool Whether already notified
*/
public function wasExpirationNotified(int $licenseId, string $notificationType): bool
{
$license = $this->getLicenseById($licenseId);
if (!$license) {
return true; // Consider notified if license doesn't exist
}
$metaKey = '_wclp_expiration_notified_' . sanitize_key($notificationType) . '_' . $licenseId;
return (bool) get_user_meta($license->getCustomerId(), $metaKey, true);
}
/**
* Import a license from CSV data
*
* @param string $licenseKey License key
* @param int $productId Product ID
* @param int $customerId Customer ID
* @param string $domain Domain name
* @param int $orderId Order ID (optional)
* @param string $status License status
* @param int $maxActivations Maximum activations
* @param int $activationsCount Current activation count
* @param \DateTimeImmutable|null $expiresAt Expiration date or null for lifetime
* @return bool Success
*/
public function importLicense(
string $licenseKey,
int $productId,
int $customerId,
string $domain,
int $orderId = 0,
string $status = License::STATUS_ACTIVE,
int $maxActivations = 1,
int $activationsCount = 1,
?\DateTimeImmutable $expiresAt = null
): bool {
global $wpdb;
$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' => null,
'status' => $status,
'activations_count' => $activationsCount,
'max_activations' => $maxActivations,
'expires_at' => $expiresAt ? $expiresAt->format('Y-m-d H:i:s') : null,
],
['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s']
);
return $result !== false;
}
/**
* Export all licenses to array format suitable for CSV
*
* @return array Array of license data for CSV export
*/
public function exportLicensesForCsv(): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$rows = $wpdb->get_results(
"SELECT * FROM {$tableName} ORDER BY created_at DESC",
ARRAY_A
);
$exportData = [];
foreach ($rows ?: [] as $row) {
$product = wc_get_product((int) $row['product_id']);
$customer = get_userdata((int) $row['customer_id']);
$order = wc_get_order((int) $row['order_id']);
$exportData[] = [
'ID' => $row['id'],
'License Key' => $row['license_key'],
'Product' => $product ? $product->get_name() : 'Unknown',
'Product ID' => $row['product_id'],
'Order ID' => $row['order_id'],
'Order Number' => $order ? $order->get_order_number() : '',
'Customer' => $customer ? $customer->display_name : 'Guest',
'Customer Email' => $customer ? $customer->user_email : '',
'Customer ID' => $row['customer_id'],
'Domain' => $row['domain'],
'Status' => ucfirst($row['status']),
'Activations' => $row['activations_count'],
'Max Activations' => $row['max_activations'],
'Expires At' => $row['expires_at'] ?: 'Lifetime',
'Created At' => $row['created_at'],
'Updated At' => $row['updated_at'],
];
}
return $exportData;
}
}

View File

@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct;
use Jeremias\WcLicensedProduct\Admin\AdminController;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
use Jeremias\WcLicensedProduct\Api\RestApiController;
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
@@ -117,6 +118,7 @@ final class Plugin
if (is_admin()) {
new AdminController($this->twig, $this->licenseManager);
new VersionAdminController($this->versionManager);
new SettingsController();
}
}

View File

@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use WC_Product;
/**
@@ -55,28 +56,68 @@ class LicensedProduct extends WC_Product
/**
* Get max activations for this product
* Falls back to default settings if not set on product
*/
public function get_max_activations(): int
{
$value = $this->get_meta('_licensed_max_activations', true);
return $value ? (int) $value : 1;
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
* Falls back to default settings if not set on product
*/
public function get_validity_days(): ?int
{
$value = $this->get_meta('_licensed_validity_days', true);
return $value !== '' ? (int) $value : null;
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
{
return $this->get_meta('_licensed_bind_to_version', true) === 'yes';
$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;
}
/**

View File

@@ -9,6 +9,8 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
/**
* Registers and handles the Licensed product type for WooCommerce
*/
@@ -85,39 +87,82 @@ final class LicensedProductType
public function addProductDataPanel(): void
{
global $post;
// Get current product values
$currentMaxActivations = get_post_meta($post->ID, '_licensed_max_activations', true);
$currentValidityDays = get_post_meta($post->ID, '_licensed_validity_days', true);
$currentBindToVersion = get_post_meta($post->ID, '_licensed_bind_to_version', true);
// Get default values
$defaultMaxActivations = SettingsController::getDefaultMaxActivations();
$defaultValidityDays = SettingsController::getDefaultValidityDays();
$defaultBindToVersion = SettingsController::getDefaultBindToVersion();
// Format default validity for display
$defaultValidityDisplay = $defaultValidityDays !== null
? sprintf(__('%d days', 'wc-licensed-product'), $defaultValidityDays)
: __('Lifetime', 'wc-licensed-product');
?>
<div id="licensed_product_data" class="panel woocommerce_options_panel hidden">
<div class="options_group">
<p class="form-field">
<em><?php
printf(
/* translators: %s: URL to settings page */
esc_html__('Leave fields empty to use default settings from %s.', 'wc-licensed-product'),
'<a href="' . esc_url(admin_url('admin.php?page=wc-settings&tab=licensed_product')) . '">' .
esc_html__('WooCommerce > Settings > Licensed Products', 'wc-licensed-product') . '</a>'
);
?></em>
</p>
<?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'),
'description' => sprintf(
/* translators: %d: default max activations value */
__('Maximum number of domain activations per license. Default: %d', 'wc-licensed-product'),
$defaultMaxActivations
),
'desc_tip' => true,
'type' => 'number',
'custom_attributes' => [
'min' => '1',
'step' => '1',
],
'value' => get_post_meta($post->ID, '_licensed_max_activations', true) ?: '1',
'placeholder' => (string) $defaultMaxActivations,
'value' => $currentMaxActivations,
]);
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'),
'description' => sprintf(
/* translators: %s: default validity value */
__('Number of days the license is valid. Leave empty for default (%s).', 'wc-licensed-product'),
$defaultValidityDisplay
),
'desc_tip' => true,
'type' => 'number',
'custom_attributes' => [
'min' => '0',
'step' => '1',
],
'placeholder' => $defaultValidityDays !== null ? (string) $defaultValidityDays : __('Lifetime', 'wc-licensed-product'),
'value' => $currentValidityDays,
]);
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'),
'description' => sprintf(
/* translators: %s: default bind to version value (Yes/No) */
__('If enabled, licenses are bound to the major version at purchase time. Default: %s', 'wc-licensed-product'),
$defaultBindToVersion ? __('Yes', 'wc-licensed-product') : __('No', 'wc-licensed-product')
),
'value' => $currentBindToVersion ?: ($defaultBindToVersion ? 'yes' : 'no'),
'cbvalue' => 'yes',
]);
woocommerce_wp_text_input([
@@ -160,19 +205,26 @@ final class LicensedProductType
public function saveProductMeta(int $postId): void
{
// Verify nonce is handled by WooCommerce
$maxActivations = isset($_POST['_licensed_max_activations'])
// Allow empty values to fall back to defaults
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified by WooCommerce
$maxActivations = isset($_POST['_licensed_max_activations']) && $_POST['_licensed_max_activations'] !== ''
? absint($_POST['_licensed_max_activations'])
: 1;
: '';
update_post_meta($postId, '_licensed_max_activations', $maxActivations);
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$validityDays = isset($_POST['_licensed_validity_days']) && $_POST['_licensed_validity_days'] !== ''
? absint($_POST['_licensed_validity_days'])
: '';
update_post_meta($postId, '_licensed_validity_days', $validityDays);
// For checkbox, we need to distinguish between "not set" and "explicitly unchecked"
// If the hidden field is present, the form was submitted and we save the actual value
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$bindToVersion = isset($_POST['_licensed_bind_to_version']) ? 'yes' : 'no';
update_post_meta($postId, '_licensed_bind_to_version', $bindToVersion);
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$currentVersion = isset($_POST['_licensed_current_version'])
? sanitize_text_field($_POST['_licensed_current_version'])
: '';