You've already forked wc-licensed-product
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:
File diff suppressed because it is too large
Load Diff
142
src/Admin/SettingsController.php
Normal file
142
src/Admin/SettingsController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'); ?>">×</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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'])
|
||||
: '';
|
||||
|
||||
Reference in New Issue
Block a user