You've already forked wc-licensed-product
Implement version 0.0.1 - Licensed Product type for WooCommerce
Add complete plugin infrastructure for selling software with license keys: - New "Licensed Product" WooCommerce product type - License key generation (XXXX-XXXX-XXXX-XXXX format) on order completion - Domain-based license validation system - REST API endpoints (validate, status, activate, deactivate) - Customer My Account "Licenses" page - Admin license management under WooCommerce > Licenses - Checkout domain field for licensed products - Custom database tables for licenses and product versions - Twig template engine integration - Full i18n support with German (de_CH) translation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
412
src/Admin/AdminController.php
Normal file
412
src/Admin/AdminController.php
Normal file
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin Controller
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Admin
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Admin;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\License;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Handles admin pages for license management
|
||||
*/
|
||||
final class AdminController
|
||||
{
|
||||
private Environment $twig;
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
public function __construct(Environment $twig, LicenseManager $licenseManager)
|
||||
{
|
||||
$this->twig = $twig;
|
||||
$this->licenseManager = $licenseManager;
|
||||
$this->registerHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
// Add admin menu
|
||||
add_action('admin_menu', [$this, 'addAdminMenu']);
|
||||
|
||||
// Enqueue admin styles
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueueStyles']);
|
||||
|
||||
// Handle admin actions
|
||||
add_action('admin_init', [$this, 'handleAdminActions']);
|
||||
|
||||
// Add licenses column to orders list
|
||||
add_filter('manage_edit-shop_order_columns', [$this, 'addOrdersLicenseColumn']);
|
||||
add_action('manage_shop_order_posts_custom_column', [$this, 'displayOrdersLicenseColumn'], 10, 2);
|
||||
|
||||
// HPOS compatibility
|
||||
add_filter('woocommerce_shop_order_list_table_columns', [$this, 'addOrdersLicenseColumn']);
|
||||
add_action('woocommerce_shop_order_list_table_custom_column', [$this, 'displayOrdersLicenseColumnHpos'], 10, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add admin menu pages
|
||||
*/
|
||||
public function addAdminMenu(): void
|
||||
{
|
||||
add_submenu_page(
|
||||
'woocommerce',
|
||||
__('Licenses', 'wc-licensed-product'),
|
||||
__('Licenses', 'wc-licensed-product'),
|
||||
'manage_woocommerce',
|
||||
'wc-licenses',
|
||||
[$this, 'renderLicensesPage']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue admin styles and scripts
|
||||
*/
|
||||
public function enqueueStyles(string $hook): void
|
||||
{
|
||||
if ($hook !== 'woocommerce_page_wc-licenses') {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'wc-licensed-product-admin',
|
||||
WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/admin.css',
|
||||
[],
|
||||
WC_LICENSED_PRODUCT_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle admin actions (update, delete licenses)
|
||||
*/
|
||||
public function handleAdminActions(): void
|
||||
{
|
||||
if (!isset($_GET['page']) || $_GET['page'] !== 'wc-licenses') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!current_user_can('manage_woocommerce')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle status update
|
||||
if (isset($_POST['action']) && $_POST['action'] === 'update_license_status') {
|
||||
$this->handleStatusUpdate();
|
||||
}
|
||||
|
||||
// Handle delete
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'delete' && isset($_GET['license_id'])) {
|
||||
$this->handleDelete();
|
||||
}
|
||||
|
||||
// Handle revoke
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'revoke' && isset($_GET['license_id'])) {
|
||||
$this->handleRevoke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle license status update
|
||||
*/
|
||||
private function handleStatusUpdate(): void
|
||||
{
|
||||
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'update_license_status')) {
|
||||
wp_die(__('Security check failed.', 'wc-licensed-product'));
|
||||
}
|
||||
|
||||
$licenseId = absint($_POST['license_id'] ?? 0);
|
||||
$status = sanitize_text_field($_POST['status'] ?? '');
|
||||
|
||||
if ($licenseId && in_array($status, [License::STATUS_ACTIVE, License::STATUS_INACTIVE, License::STATUS_REVOKED], true)) {
|
||||
$this->licenseManager->updateLicenseStatus($licenseId, $status);
|
||||
|
||||
wp_redirect(admin_url('admin.php?page=wc-licenses&updated=1'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle license deletion
|
||||
*/
|
||||
private function handleDelete(): void
|
||||
{
|
||||
if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'delete_license')) {
|
||||
wp_die(__('Security check failed.', 'wc-licensed-product'));
|
||||
}
|
||||
|
||||
$licenseId = absint($_GET['license_id'] ?? 0);
|
||||
if ($licenseId) {
|
||||
$this->licenseManager->deleteLicense($licenseId);
|
||||
|
||||
wp_redirect(admin_url('admin.php?page=wc-licenses&deleted=1'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle license revocation
|
||||
*/
|
||||
private function handleRevoke(): void
|
||||
{
|
||||
if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'revoke_license')) {
|
||||
wp_die(__('Security check failed.', 'wc-licensed-product'));
|
||||
}
|
||||
|
||||
$licenseId = absint($_GET['license_id'] ?? 0);
|
||||
if ($licenseId) {
|
||||
$this->licenseManager->updateLicenseStatus($licenseId, License::STATUS_REVOKED);
|
||||
|
||||
wp_redirect(admin_url('admin.php?page=wc-licenses&revoked=1'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render licenses admin page
|
||||
*/
|
||||
public function renderLicensesPage(): void
|
||||
{
|
||||
$page = isset($_GET['paged']) ? absint($_GET['paged']) : 1;
|
||||
$perPage = 20;
|
||||
|
||||
$licenses = $this->licenseManager->getAllLicenses($page, $perPage);
|
||||
$totalLicenses = $this->licenseManager->getLicenseCount();
|
||||
$totalPages = ceil($totalLicenses / $perPage);
|
||||
|
||||
// Enrich licenses with related data
|
||||
$enrichedLicenses = [];
|
||||
foreach ($licenses as $license) {
|
||||
$product = wc_get_product($license->getProductId());
|
||||
$order = wc_get_order($license->getOrderId());
|
||||
$customer = get_userdata($license->getCustomerId());
|
||||
|
||||
$enrichedLicenses[] = [
|
||||
'license' => $license,
|
||||
'product_name' => $product ? $product->get_name() : __('Unknown', 'wc-licensed-product'),
|
||||
'product_edit_url' => $product ? get_edit_post_link($product->get_id()) : '',
|
||||
'order_number' => $order ? $order->get_order_number() : '',
|
||||
'order_edit_url' => $order ? $order->get_edit_order_url() : '',
|
||||
'customer_name' => $customer ? $customer->display_name : __('Guest', 'wc-licensed-product'),
|
||||
'customer_email' => $customer ? $customer->user_email : '',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
echo $this->twig->render('admin/licenses.html.twig', [
|
||||
'licenses' => $enrichedLicenses,
|
||||
'current_page' => $page,
|
||||
'total_pages' => $totalPages,
|
||||
'total_licenses' => $totalLicenses,
|
||||
'admin_url' => admin_url('admin.php?page=wc-licenses'),
|
||||
'notices' => $this->getNotices(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to PHP template
|
||||
$this->renderLicensesPageFallback($enrichedLicenses, $page, $totalPages, $totalLicenses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin notices
|
||||
*/
|
||||
private function getNotices(): array
|
||||
{
|
||||
$notices = [];
|
||||
|
||||
if (isset($_GET['updated'])) {
|
||||
$notices[] = ['type' => 'success', 'message' => __('License updated successfully.', 'wc-licensed-product')];
|
||||
}
|
||||
if (isset($_GET['deleted'])) {
|
||||
$notices[] = ['type' => 'success', 'message' => __('License deleted successfully.', 'wc-licensed-product')];
|
||||
}
|
||||
if (isset($_GET['revoked'])) {
|
||||
$notices[] = ['type' => 'success', 'message' => __('License revoked successfully.', 'wc-licensed-product')];
|
||||
}
|
||||
|
||||
return $notices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback render for licenses page
|
||||
*/
|
||||
private function renderLicensesPageFallback(array $enrichedLicenses, int $page, int $totalPages, int $totalLicenses): void
|
||||
{
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h1>
|
||||
|
||||
<?php foreach ($this->getNotices() as $notice): ?>
|
||||
<div class="notice notice-<?php echo esc_attr($notice['type']); ?> is-dismissible">
|
||||
<p><?php echo esc_html($notice['message']); ?></p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Product', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Customer', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Actions', 'wc-licensed-product'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($enrichedLicenses)): ?>
|
||||
<tr>
|
||||
<td colspan="7"><?php esc_html_e('No licenses found.', 'wc-licensed-product'); ?></td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($enrichedLicenses as $item): ?>
|
||||
<tr>
|
||||
<td><code><?php echo esc_html($item['license']->getLicenseKey()); ?></code></td>
|
||||
<td>
|
||||
<?php if ($item['product_edit_url']): ?>
|
||||
<a href="<?php echo esc_url($item['product_edit_url']); ?>">
|
||||
<?php echo esc_html($item['product_name']); ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<?php echo esc_html($item['product_name']); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo esc_html($item['customer_name']); ?>
|
||||
<?php if ($item['customer_email']): ?>
|
||||
<br><small><?php echo esc_html($item['customer_email']); ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($item['license']->getDomain()); ?></td>
|
||||
<td>
|
||||
<span class="license-status license-status-<?php echo esc_attr($item['license']->getStatus()); ?>">
|
||||
<?php echo esc_html(ucfirst($item['license']->getStatus())); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
$expiresAt = $item['license']->getExpiresAt();
|
||||
echo $expiresAt
|
||||
? esc_html($expiresAt->format(get_option('date_format')))
|
||||
: esc_html__('Never', 'wc-licensed-product');
|
||||
?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($item['license']->getStatus() !== License::STATUS_REVOKED): ?>
|
||||
<a href="<?php echo esc_url(wp_nonce_url(
|
||||
admin_url('admin.php?page=wc-licenses&action=revoke&license_id=' . $item['license']->getId()),
|
||||
'revoke_license'
|
||||
)); ?>" class="button button-small" onclick="return confirm('<?php esc_attr_e('Are you sure?', 'wc-licensed-product'); ?>')">
|
||||
<?php esc_html_e('Revoke', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?php echo esc_url(wp_nonce_url(
|
||||
admin_url('admin.php?page=wc-licenses&action=delete&license_id=' . $item['license']->getId()),
|
||||
'delete_license'
|
||||
)); ?>" class="button button-small button-link-delete" onclick="return confirm('<?php esc_attr_e('Are you sure you want to delete this license?', 'wc-licensed-product'); ?>')">
|
||||
<?php esc_html_e('Delete', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="tablenav bottom">
|
||||
<div class="tablenav-pages">
|
||||
<?php
|
||||
echo paginate_links([
|
||||
'base' => admin_url('admin.php?page=wc-licenses&paged=%#%'),
|
||||
'format' => '',
|
||||
'current' => $page,
|
||||
'total' => $totalPages,
|
||||
]);
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Add license column to orders list
|
||||
*/
|
||||
public function addOrdersLicenseColumn(array $columns): array
|
||||
{
|
||||
$newColumns = [];
|
||||
foreach ($columns as $key => $value) {
|
||||
$newColumns[$key] = $value;
|
||||
if ($key === 'order_status') {
|
||||
$newColumns['license'] = __('License', 'wc-licensed-product');
|
||||
}
|
||||
}
|
||||
return $newColumns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display license column content
|
||||
*/
|
||||
public function displayOrdersLicenseColumn(string $column, int $postId): void
|
||||
{
|
||||
if ($column !== 'license') {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = wc_get_order($postId);
|
||||
$this->outputLicenseColumnContent($order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display license column content (HPOS)
|
||||
*/
|
||||
public function displayOrdersLicenseColumnHpos(string $column, \WC_Order $order): void
|
||||
{
|
||||
if ($column !== 'license') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->outputLicenseColumnContent($order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output license column content
|
||||
*/
|
||||
private function outputLicenseColumnContent(?\WC_Order $order): void
|
||||
{
|
||||
if (!$order) {
|
||||
echo '—';
|
||||
return;
|
||||
}
|
||||
|
||||
$hasLicensedProduct = false;
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
$hasLicensedProduct = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasLicensedProduct) {
|
||||
echo '—';
|
||||
return;
|
||||
}
|
||||
|
||||
$domain = $order->get_meta('_licensed_product_domain');
|
||||
if ($domain) {
|
||||
echo '<span class="dashicons dashicons-admin-network"></span> ' . esc_html($domain);
|
||||
} else {
|
||||
echo '<span class="dashicons dashicons-warning" title="' . esc_attr__('No domain specified', 'wc-licensed-product') . '"></span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
271
src/Api/RestApiController.php
Normal file
271
src/Api/RestApiController.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
/**
|
||||
* REST API Controller
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Api
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Api;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_REST_Server;
|
||||
|
||||
/**
|
||||
* Handles REST API endpoints for license validation
|
||||
*/
|
||||
final class RestApiController
|
||||
{
|
||||
private const NAMESPACE = 'wc-licensed-product/v1';
|
||||
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
public function __construct(LicenseManager $licenseManager)
|
||||
{
|
||||
$this->licenseManager = $licenseManager;
|
||||
$this->registerHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
add_action('rest_api_init', [$this, 'registerRoutes']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API routes
|
||||
*/
|
||||
public function registerRoutes(): void
|
||||
{
|
||||
// Validate license endpoint (public)
|
||||
register_rest_route(self::NAMESPACE, '/validate', [
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'validateLicense'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'license_key' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => function ($value): bool {
|
||||
return !empty($value) && strlen($value) <= 64;
|
||||
},
|
||||
],
|
||||
'domain' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => function ($value): bool {
|
||||
return !empty($value) && strlen($value) <= 255;
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Check license status endpoint (public)
|
||||
register_rest_route(self::NAMESPACE, '/status', [
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'checkStatus'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'license_key' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Activate license on domain endpoint (public)
|
||||
register_rest_route(self::NAMESPACE, '/activate', [
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'activateLicense'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'license_key' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
'domain' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Deactivate license endpoint (public)
|
||||
register_rest_route(self::NAMESPACE, '/deactivate', [
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'deactivateLicense'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'license_key' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
'domain' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate license endpoint
|
||||
*/
|
||||
public function validateLicense(WP_REST_Request $request): WP_REST_Response
|
||||
{
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$domain = $request->get_param('domain');
|
||||
|
||||
$result = $this->licenseManager->validateLicense($licenseKey, $domain);
|
||||
|
||||
$statusCode = $result['valid'] ? 200 : 403;
|
||||
|
||||
return new WP_REST_Response($result, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check license status endpoint
|
||||
*/
|
||||
public function checkStatus(WP_REST_Request $request): WP_REST_Response
|
||||
{
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
||||
|
||||
if (!$license) {
|
||||
return new WP_REST_Response([
|
||||
'valid' => false,
|
||||
'error' => 'license_not_found',
|
||||
'message' => __('License key not found.', 'wc-licensed-product'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'valid' => $license->isValid(),
|
||||
'status' => $license->getStatus(),
|
||||
'domain' => $license->getDomain(),
|
||||
'expires_at' => $license->getExpiresAt()?->format('Y-m-d'),
|
||||
'activations_count' => $license->getActivationsCount(),
|
||||
'max_activations' => $license->getMaxActivations(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate license on domain endpoint
|
||||
*/
|
||||
public function activateLicense(WP_REST_Request $request): WP_REST_Response
|
||||
{
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$domain = $request->get_param('domain');
|
||||
|
||||
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
||||
|
||||
if (!$license) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'license_not_found',
|
||||
'message' => __('License key not found.', 'wc-licensed-product'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (!$license->isValid()) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'license_invalid',
|
||||
'message' => __('This license is not valid.', 'wc-licensed-product'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
|
||||
// Check if already activated on this domain
|
||||
if ($license->getDomain() === $normalizedDomain) {
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => __('License is already activated for this domain.', 'wc-licensed-product'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if can activate on another domain
|
||||
if (!$license->canActivate()) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'max_activations_reached',
|
||||
'message' => __('Maximum number of activations reached.', 'wc-licensed-product'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Update domain (in this simple implementation, we replace the domain)
|
||||
$success = $this->licenseManager->updateLicenseDomain($license->getId(), $domain);
|
||||
|
||||
if (!$success) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'activation_failed',
|
||||
'message' => __('Failed to activate license.', 'wc-licensed-product'),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => __('License activated successfully.', 'wc-licensed-product'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate license endpoint
|
||||
*/
|
||||
public function deactivateLicense(WP_REST_Request $request): WP_REST_Response
|
||||
{
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$domain = $request->get_param('domain');
|
||||
|
||||
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
||||
|
||||
if (!$license) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'license_not_found',
|
||||
'message' => __('License key not found.', 'wc-licensed-product'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
|
||||
// Verify domain matches
|
||||
if ($license->getDomain() !== $normalizedDomain) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'domain_mismatch',
|
||||
'message' => __('License is not activated for this domain.', 'wc-licensed-product'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Set status to inactive
|
||||
$success = $this->licenseManager->updateLicenseStatus($license->getId(), 'inactive');
|
||||
|
||||
if (!$success) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'deactivation_failed',
|
||||
'message' => __('Failed to deactivate license.', 'wc-licensed-product'),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => __('License deactivated successfully.', 'wc-licensed-product'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
207
src/Checkout/CheckoutController.php
Normal file
207
src/Checkout/CheckoutController.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
/**
|
||||
* Checkout Controller
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Checkout
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Checkout;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
|
||||
/**
|
||||
* Handles checkout modifications for licensed products
|
||||
*/
|
||||
final class CheckoutController
|
||||
{
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
public function __construct(LicenseManager $licenseManager)
|
||||
{
|
||||
$this->licenseManager = $licenseManager;
|
||||
$this->registerHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
// Add domain field to checkout
|
||||
add_action('woocommerce_after_order_notes', [$this, 'addDomainField']);
|
||||
|
||||
// Validate domain field
|
||||
add_action('woocommerce_checkout_process', [$this, 'validateDomainField']);
|
||||
|
||||
// Save domain field to order meta
|
||||
add_action('woocommerce_checkout_update_order_meta', [$this, 'saveDomainField']);
|
||||
|
||||
// Display domain in order details (admin)
|
||||
add_action('woocommerce_admin_order_data_after_billing_address', [$this, 'displayDomainInAdmin']);
|
||||
|
||||
// Display domain in order email
|
||||
add_action('woocommerce_email_after_order_table', [$this, 'displayDomainInEmail'], 10, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cart contains licensed products
|
||||
*/
|
||||
private function cartHasLicensedProducts(): bool
|
||||
{
|
||||
if (!WC()->cart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (WC()->cart->get_cart() as $cartItem) {
|
||||
$product = $cartItem['data'];
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add domain field to checkout form
|
||||
*/
|
||||
public function addDomainField(): void
|
||||
{
|
||||
if (!$this->cartHasLicensedProducts()) {
|
||||
return;
|
||||
}
|
||||
|
||||
?>
|
||||
<div id="licensed-product-domain-field">
|
||||
<h3><?php esc_html_e('License Domain', 'wc-licensed-product'); ?></h3>
|
||||
<p class="form-row form-row-wide">
|
||||
<label for="licensed_product_domain">
|
||||
<?php esc_html_e('Domain for License Activation', 'wc-licensed-product'); ?>
|
||||
<abbr class="required" title="<?php esc_attr_e('required', 'wc-licensed-product'); ?>">*</abbr>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input-text"
|
||||
name="licensed_product_domain"
|
||||
id="licensed_product_domain"
|
||||
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>"
|
||||
value="<?php echo esc_attr(WC()->checkout->get_value('licensed_product_domain')); ?>"
|
||||
/>
|
||||
<span class="description">
|
||||
<?php esc_html_e('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'); ?>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate domain field during checkout
|
||||
*/
|
||||
public function validateDomainField(): void
|
||||
{
|
||||
if (!$this->cartHasLicensedProducts()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$domain = isset($_POST['licensed_product_domain'])
|
||||
? sanitize_text_field($_POST['licensed_product_domain'])
|
||||
: '';
|
||||
|
||||
if (empty($domain)) {
|
||||
wc_add_notice(
|
||||
__('Please enter a domain for your license activation.', 'wc-licensed-product'),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
if (!$this->isValidDomain($normalizedDomain)) {
|
||||
wc_add_notice(
|
||||
__('Please enter a valid domain name.', 'wc-licensed-product'),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save domain field to order meta
|
||||
*/
|
||||
public function saveDomainField(int $orderId): void
|
||||
{
|
||||
if (!$this->cartHasLicensedProducts()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($_POST['licensed_product_domain']) && !empty($_POST['licensed_product_domain'])) {
|
||||
$domain = sanitize_text_field($_POST['licensed_product_domain']);
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
|
||||
$order = wc_get_order($orderId);
|
||||
if ($order) {
|
||||
$order->update_meta_data('_licensed_product_domain', $normalizedDomain);
|
||||
$order->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display domain in admin order view
|
||||
*/
|
||||
public function displayDomainInAdmin(\WC_Order $order): void
|
||||
{
|
||||
$domain = $order->get_meta('_licensed_product_domain');
|
||||
if (!$domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
?>
|
||||
<p>
|
||||
<strong><?php esc_html_e('License Domain:', 'wc-licensed-product'); ?></strong>
|
||||
<?php echo esc_html($domain); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Display domain in order emails
|
||||
*/
|
||||
public function displayDomainInEmail(\WC_Order $order, bool $sentToAdmin, bool $plainText): void
|
||||
{
|
||||
$domain = $order->get_meta('_licensed_product_domain');
|
||||
if (!$domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($plainText) {
|
||||
echo "\n" . esc_html__('License Domain:', 'wc-licensed-product') . ' ' . esc_html($domain) . "\n";
|
||||
} else {
|
||||
?>
|
||||
<p>
|
||||
<strong><?php esc_html_e('License Domain:', 'wc-licensed-product'); ?></strong>
|
||||
<?php echo esc_html($domain); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate domain format
|
||||
*/
|
||||
private function isValidDomain(string $domain): bool
|
||||
{
|
||||
// Basic domain validation
|
||||
if (strlen($domain) > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for valid domain pattern
|
||||
$pattern = '/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/';
|
||||
|
||||
return (bool) preg_match($pattern, $domain);
|
||||
}
|
||||
}
|
||||
187
src/Frontend/AccountController.php
Normal file
187
src/Frontend/AccountController.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
/**
|
||||
* Frontend Account Controller
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Frontend
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Frontend;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Handles customer account pages for viewing licenses
|
||||
*/
|
||||
final class AccountController
|
||||
{
|
||||
private Environment $twig;
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
public function __construct(Environment $twig, LicenseManager $licenseManager)
|
||||
{
|
||||
$this->twig = $twig;
|
||||
$this->licenseManager = $licenseManager;
|
||||
$this->registerHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
// Add licenses endpoint
|
||||
add_action('init', [$this, 'addLicensesEndpoint']);
|
||||
|
||||
// Add licenses menu item
|
||||
add_filter('woocommerce_account_menu_items', [$this, 'addLicensesMenuItem']);
|
||||
|
||||
// Add licenses endpoint content
|
||||
add_action('woocommerce_account_licenses_endpoint', [$this, 'displayLicensesContent']);
|
||||
|
||||
// Enqueue frontend styles
|
||||
add_action('wp_enqueue_scripts', [$this, 'enqueueStyles']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add licenses endpoint for My Account
|
||||
*/
|
||||
public function addLicensesEndpoint(): void
|
||||
{
|
||||
add_rewrite_endpoint('licenses', EP_ROOT | EP_PAGES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add licenses menu item to My Account navigation
|
||||
*/
|
||||
public function addLicensesMenuItem(array $items): array
|
||||
{
|
||||
// Insert licenses after orders
|
||||
$newItems = [];
|
||||
foreach ($items as $key => $value) {
|
||||
$newItems[$key] = $value;
|
||||
if ($key === 'orders') {
|
||||
$newItems['licenses'] = __('Licenses', 'wc-licensed-product');
|
||||
}
|
||||
}
|
||||
|
||||
return $newItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display licenses content in My Account
|
||||
*/
|
||||
public function displayLicensesContent(): void
|
||||
{
|
||||
$customerId = get_current_user_id();
|
||||
if (!$customerId) {
|
||||
echo '<p>' . esc_html__('Please log in to view your licenses.', 'wc-licensed-product') . '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
$licenses = $this->licenseManager->getLicensesByCustomer($customerId);
|
||||
|
||||
// Enrich licenses with product data
|
||||
$enrichedLicenses = [];
|
||||
foreach ($licenses as $license) {
|
||||
$product = wc_get_product($license->getProductId());
|
||||
$order = wc_get_order($license->getOrderId());
|
||||
|
||||
$enrichedLicenses[] = [
|
||||
'license' => $license,
|
||||
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
|
||||
'product_url' => $product ? $product->get_permalink() : '',
|
||||
'order_number' => $order ? $order->get_order_number() : '',
|
||||
'order_url' => $order ? $order->get_view_order_url() : '',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
echo $this->twig->render('frontend/licenses.html.twig', [
|
||||
'licenses' => $enrichedLicenses,
|
||||
'has_licenses' => !empty($enrichedLicenses),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to PHP template if Twig fails
|
||||
$this->displayLicensesFallback($enrichedLicenses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback display method if Twig is unavailable
|
||||
*/
|
||||
private function displayLicensesFallback(array $enrichedLicenses): void
|
||||
{
|
||||
if (empty($enrichedLicenses)) {
|
||||
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
?>
|
||||
<table class="woocommerce-licenses-table shop_table shop_table_responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Product', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($enrichedLicenses as $item): ?>
|
||||
<tr>
|
||||
<td data-title="<?php esc_attr_e('License Key', 'wc-licensed-product'); ?>">
|
||||
<code><?php echo esc_html($item['license']->getLicenseKey()); ?></code>
|
||||
</td>
|
||||
<td data-title="<?php esc_attr_e('Product', 'wc-licensed-product'); ?>">
|
||||
<?php if ($item['product_url']): ?>
|
||||
<a href="<?php echo esc_url($item['product_url']); ?>">
|
||||
<?php echo esc_html($item['product_name']); ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<?php echo esc_html($item['product_name']); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td data-title="<?php esc_attr_e('Domain', 'wc-licensed-product'); ?>">
|
||||
<?php echo esc_html($item['license']->getDomain()); ?>
|
||||
</td>
|
||||
<td data-title="<?php esc_attr_e('Status', 'wc-licensed-product'); ?>">
|
||||
<span class="license-status license-status-<?php echo esc_attr($item['license']->getStatus()); ?>">
|
||||
<?php echo esc_html(ucfirst($item['license']->getStatus())); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td data-title="<?php esc_attr_e('Expires', 'wc-licensed-product'); ?>">
|
||||
<?php
|
||||
$expiresAt = $item['license']->getExpiresAt();
|
||||
echo $expiresAt
|
||||
? esc_html($expiresAt->format(get_option('date_format')))
|
||||
: esc_html__('Never', 'wc-licensed-product');
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue frontend styles
|
||||
*/
|
||||
public function enqueueStyles(): void
|
||||
{
|
||||
if (!is_account_page()) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'wc-licensed-product-frontend',
|
||||
WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/frontend.css',
|
||||
[],
|
||||
WC_LICENSED_PRODUCT_VERSION
|
||||
);
|
||||
}
|
||||
}
|
||||
138
src/Installer.php
Normal file
138
src/Installer.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Installer
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct;
|
||||
|
||||
/**
|
||||
* Handles plugin activation and deactivation
|
||||
*/
|
||||
final class Installer
|
||||
{
|
||||
/**
|
||||
* Database table name for licenses
|
||||
*/
|
||||
public const TABLE_LICENSES = 'wc_licensed_product_licenses';
|
||||
|
||||
/**
|
||||
* Database table name for product versions
|
||||
*/
|
||||
public const TABLE_PRODUCT_VERSIONS = 'wc_licensed_product_versions';
|
||||
|
||||
/**
|
||||
* Run on plugin activation
|
||||
*/
|
||||
public static function activate(): void
|
||||
{
|
||||
self::createTables();
|
||||
self::createCacheDir();
|
||||
|
||||
// Set version in options
|
||||
update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION);
|
||||
|
||||
// Flush rewrite rules for REST API
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run on plugin deactivation
|
||||
*/
|
||||
public static function deactivate(): void
|
||||
{
|
||||
// Flush rewrite rules
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database tables
|
||||
*/
|
||||
private static function createTables(): void
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$charsetCollate = $wpdb->get_charset_collate();
|
||||
|
||||
$licensesTable = $wpdb->prefix . self::TABLE_LICENSES;
|
||||
$versionsTable = $wpdb->prefix . self::TABLE_PRODUCT_VERSIONS;
|
||||
|
||||
$sqlLicenses = "CREATE TABLE {$licensesTable} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
license_key VARCHAR(64) NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
product_id BIGINT UNSIGNED NOT NULL,
|
||||
customer_id BIGINT UNSIGNED NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
version_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
status ENUM('active', 'inactive', 'expired', 'revoked') NOT NULL DEFAULT 'active',
|
||||
activations_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
max_activations INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
expires_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY license_key (license_key),
|
||||
KEY order_id (order_id),
|
||||
KEY product_id (product_id),
|
||||
KEY customer_id (customer_id),
|
||||
KEY domain (domain),
|
||||
KEY status (status)
|
||||
) {$charsetCollate};";
|
||||
|
||||
$sqlVersions = "CREATE TABLE {$versionsTable} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
product_id BIGINT UNSIGNED NOT NULL,
|
||||
version VARCHAR(32) NOT NULL,
|
||||
major_version INT UNSIGNED NOT NULL,
|
||||
minor_version INT UNSIGNED NOT NULL,
|
||||
patch_version INT UNSIGNED NOT NULL,
|
||||
release_notes TEXT DEFAULT NULL,
|
||||
download_url VARCHAR(512) DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY product_version (product_id, version),
|
||||
KEY product_id (product_id),
|
||||
KEY major_version (major_version),
|
||||
KEY is_active (is_active)
|
||||
) {$charsetCollate};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta($sqlLicenses);
|
||||
dbDelta($sqlVersions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Twig cache directory
|
||||
*/
|
||||
private static function createCacheDir(): void
|
||||
{
|
||||
$cacheDir = WP_CONTENT_DIR . '/cache/wc-licensed-product/twig';
|
||||
if (!file_exists($cacheDir)) {
|
||||
wp_mkdir_p($cacheDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get licenses table name
|
||||
*/
|
||||
public static function getLicensesTable(): string
|
||||
{
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . self::TABLE_LICENSES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product versions table name
|
||||
*/
|
||||
public static function getVersionsTable(): string
|
||||
{
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . self::TABLE_PRODUCT_VERSIONS;
|
||||
}
|
||||
}
|
||||
178
src/License/License.php
Normal file
178
src/License/License.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
/**
|
||||
* License Model
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\License
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\License;
|
||||
|
||||
/**
|
||||
* License entity model
|
||||
*/
|
||||
class License
|
||||
{
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_INACTIVE = 'inactive';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_REVOKED = 'revoked';
|
||||
|
||||
private int $id;
|
||||
private string $licenseKey;
|
||||
private int $orderId;
|
||||
private int $productId;
|
||||
private int $customerId;
|
||||
private string $domain;
|
||||
private ?int $versionId;
|
||||
private string $status;
|
||||
private int $activationsCount;
|
||||
private int $maxActivations;
|
||||
private ?\DateTimeInterface $expiresAt;
|
||||
private \DateTimeInterface $createdAt;
|
||||
private \DateTimeInterface $updatedAt;
|
||||
|
||||
/**
|
||||
* Create license from database row
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$license = new self();
|
||||
$license->id = (int) $data['id'];
|
||||
$license->licenseKey = $data['license_key'];
|
||||
$license->orderId = (int) $data['order_id'];
|
||||
$license->productId = (int) $data['product_id'];
|
||||
$license->customerId = (int) $data['customer_id'];
|
||||
$license->domain = $data['domain'];
|
||||
$license->versionId = $data['version_id'] ? (int) $data['version_id'] : null;
|
||||
$license->status = $data['status'];
|
||||
$license->activationsCount = (int) $data['activations_count'];
|
||||
$license->maxActivations = (int) $data['max_activations'];
|
||||
$license->expiresAt = $data['expires_at'] ? new \DateTimeImmutable($data['expires_at']) : null;
|
||||
$license->createdAt = new \DateTimeImmutable($data['created_at']);
|
||||
$license->updatedAt = new \DateTimeImmutable($data['updated_at']);
|
||||
|
||||
return $license;
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getLicenseKey(): string
|
||||
{
|
||||
return $this->licenseKey;
|
||||
}
|
||||
|
||||
public function getOrderId(): int
|
||||
{
|
||||
return $this->orderId;
|
||||
}
|
||||
|
||||
public function getProductId(): int
|
||||
{
|
||||
return $this->productId;
|
||||
}
|
||||
|
||||
public function getCustomerId(): int
|
||||
{
|
||||
return $this->customerId;
|
||||
}
|
||||
|
||||
public function getDomain(): string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function getVersionId(): ?int
|
||||
{
|
||||
return $this->versionId;
|
||||
}
|
||||
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function getActivationsCount(): int
|
||||
{
|
||||
return $this->activationsCount;
|
||||
}
|
||||
|
||||
public function getMaxActivations(): int
|
||||
{
|
||||
return $this->maxActivations;
|
||||
}
|
||||
|
||||
public function getExpiresAt(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->expiresAt;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeInterface
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): \DateTimeInterface
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if license is currently valid
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->expiresAt !== null && $this->expiresAt < new \DateTimeImmutable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if license has expired
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expiresAt !== null && $this->expiresAt < new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if license can be activated on another domain
|
||||
*/
|
||||
public function canActivate(): bool
|
||||
{
|
||||
return $this->isValid() && $this->activationsCount < $this->maxActivations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for JSON/API responses
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'license_key' => $this->licenseKey,
|
||||
'order_id' => $this->orderId,
|
||||
'product_id' => $this->productId,
|
||||
'customer_id' => $this->customerId,
|
||||
'domain' => $this->domain,
|
||||
'version_id' => $this->versionId,
|
||||
'status' => $this->status,
|
||||
'activations_count' => $this->activationsCount,
|
||||
'max_activations' => $this->maxActivations,
|
||||
'expires_at' => $this->expiresAt?->format('Y-m-d H:i:s'),
|
||||
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->updatedAt->format('Y-m-d H:i:s'),
|
||||
'is_valid' => $this->isValid(),
|
||||
];
|
||||
}
|
||||
}
|
||||
363
src/License/LicenseManager.php
Normal file
363
src/License/LicenseManager.php
Normal file
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
/**
|
||||
* License Manager
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\License
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\License;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Installer;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedProduct;
|
||||
|
||||
/**
|
||||
* Manages license operations (CRUD, validation, generation)
|
||||
*/
|
||||
class LicenseManager
|
||||
{
|
||||
/**
|
||||
* Generate a unique license key
|
||||
*/
|
||||
public function generateLicenseKey(): string
|
||||
{
|
||||
// Format: XXXX-XXXX-XXXX-XXXX (32 chars hex, 4 groups)
|
||||
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
$key = '';
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
if ($i > 0) {
|
||||
$key .= '-';
|
||||
}
|
||||
for ($j = 0; $j < 4; $j++) {
|
||||
$key .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a license for a completed order
|
||||
*/
|
||||
public function generateLicense(
|
||||
int $orderId,
|
||||
int $productId,
|
||||
int $customerId,
|
||||
string $domain
|
||||
): ?License {
|
||||
global $wpdb;
|
||||
|
||||
// Check if license already exists for this order and product
|
||||
$existing = $this->getLicenseByOrderAndProduct($orderId, $productId);
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$product = wc_get_product($productId);
|
||||
if (!$product instanceof LicensedProduct) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate unique license key
|
||||
$licenseKey = $this->generateLicenseKey();
|
||||
while ($this->getLicenseByKey($licenseKey)) {
|
||||
$licenseKey = $this->generateLicenseKey();
|
||||
}
|
||||
|
||||
// Calculate expiration date
|
||||
$expiresAt = null;
|
||||
$validityDays = $product->get_validity_days();
|
||||
if ($validityDays !== null && $validityDays > 0) {
|
||||
$expiresAt = (new \DateTimeImmutable())->modify("+{$validityDays} days")->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
// Determine version ID if bound to version
|
||||
$versionId = null;
|
||||
if ($product->is_bound_to_version()) {
|
||||
$versionId = $this->getCurrentVersionId($productId);
|
||||
}
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$result = $wpdb->insert(
|
||||
$tableName,
|
||||
[
|
||||
'license_key' => $licenseKey,
|
||||
'order_id' => $orderId,
|
||||
'product_id' => $productId,
|
||||
'customer_id' => $customerId,
|
||||
'domain' => $this->normalizeDomain($domain),
|
||||
'version_id' => $versionId,
|
||||
'status' => License::STATUS_ACTIVE,
|
||||
'activations_count' => 1,
|
||||
'max_activations' => $product->get_max_activations(),
|
||||
'expires_at' => $expiresAt,
|
||||
],
|
||||
['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s']
|
||||
);
|
||||
|
||||
if ($result === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->getLicenseById((int) $wpdb->insert_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license by ID
|
||||
*/
|
||||
public function getLicenseById(int $id): ?License
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare("SELECT * FROM {$tableName} WHERE id = %d", $id),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $row ? License::fromArray($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license by license key
|
||||
*/
|
||||
public function getLicenseByKey(string $licenseKey): ?License
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare("SELECT * FROM {$tableName} WHERE license_key = %s", $licenseKey),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $row ? License::fromArray($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license by order and product
|
||||
*/
|
||||
public function getLicenseByOrderAndProduct(int $orderId, int $productId): ?License
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d",
|
||||
$orderId,
|
||||
$productId
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $row ? License::fromArray($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all licenses for a customer
|
||||
*/
|
||||
public function getLicensesByCustomer(int $customerId): array
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$tableName} WHERE customer_id = %d ORDER BY created_at DESC",
|
||||
$customerId
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all licenses (for admin)
|
||||
*/
|
||||
public function getAllLicenses(int $page = 1, int $perPage = 20): array
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$tableName} ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||
$perPage,
|
||||
$offset
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total license count
|
||||
*/
|
||||
public function getLicenseCount(): int
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tableName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a license key for a domain
|
||||
*/
|
||||
public function validateLicense(string $licenseKey, string $domain): array
|
||||
{
|
||||
$license = $this->getLicenseByKey($licenseKey);
|
||||
|
||||
if (!$license) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'license_not_found',
|
||||
'message' => __('License key not found.', 'wc-licensed-product'),
|
||||
];
|
||||
}
|
||||
|
||||
// Check license status
|
||||
if ($license->getStatus() === License::STATUS_REVOKED) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'license_revoked',
|
||||
'message' => __('This license has been revoked.', 'wc-licensed-product'),
|
||||
];
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if ($license->isExpired()) {
|
||||
$this->updateLicenseStatus($license->getId(), License::STATUS_EXPIRED);
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'license_expired',
|
||||
'message' => __('This license has expired.', 'wc-licensed-product'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($license->getStatus() === License::STATUS_INACTIVE) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'license_inactive',
|
||||
'message' => __('This license is inactive.', 'wc-licensed-product'),
|
||||
];
|
||||
}
|
||||
|
||||
// Check domain
|
||||
$normalizedDomain = $this->normalizeDomain($domain);
|
||||
if ($license->getDomain() !== $normalizedDomain) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'domain_mismatch',
|
||||
'message' => __('This license is not valid for this domain.', 'wc-licensed-product'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'license' => [
|
||||
'product_id' => $license->getProductId(),
|
||||
'expires_at' => $license->getExpiresAt()?->format('Y-m-d'),
|
||||
'version_id' => $license->getVersionId(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update license status
|
||||
*/
|
||||
public function updateLicenseStatus(int $licenseId, string $status): bool
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$result = $wpdb->update(
|
||||
$tableName,
|
||||
['status' => $status],
|
||||
['id' => $licenseId],
|
||||
['%s'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update license domain
|
||||
*/
|
||||
public function updateLicenseDomain(int $licenseId, string $domain): bool
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$result = $wpdb->update(
|
||||
$tableName,
|
||||
['domain' => $this->normalizeDomain($domain)],
|
||||
['id' => $licenseId],
|
||||
['%s'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a license
|
||||
*/
|
||||
public function deleteLicense(int $licenseId): bool
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$result = $wpdb->delete(
|
||||
$tableName,
|
||||
['id' => $licenseId],
|
||||
['%d']
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize domain name
|
||||
*/
|
||||
public function normalizeDomain(string $domain): string
|
||||
{
|
||||
// Remove protocol
|
||||
$domain = preg_replace('#^https?://#', '', $domain);
|
||||
|
||||
// Remove trailing slash and path
|
||||
$domain = preg_replace('#/.*$#', '', $domain);
|
||||
|
||||
// Remove www prefix
|
||||
$domain = preg_replace('#^www\.#', '', $domain);
|
||||
|
||||
// Lowercase
|
||||
return strtolower(trim($domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version ID for a product
|
||||
*/
|
||||
private function getCurrentVersionId(int $productId): ?int
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getVersionsTable();
|
||||
$versionId = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$tableName} WHERE product_id = %d AND is_active = 1 ORDER BY released_at DESC LIMIT 1",
|
||||
$productId
|
||||
)
|
||||
);
|
||||
|
||||
return $versionId ? (int) $versionId : null;
|
||||
}
|
||||
}
|
||||
163
src/Plugin.php
Normal file
163
src/Plugin.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/**
|
||||
* Main Plugin class
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Admin\AdminController;
|
||||
use Jeremias\WcLicensedProduct\Api\RestApiController;
|
||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
|
||||
use Jeremias\WcLicensedProduct\Frontend\AccountController;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
/**
|
||||
* Main plugin controller
|
||||
*/
|
||||
final class Plugin
|
||||
{
|
||||
/**
|
||||
* Singleton instance
|
||||
*/
|
||||
private static ?Plugin $instance = null;
|
||||
|
||||
/**
|
||||
* Twig environment
|
||||
*/
|
||||
private Environment $twig;
|
||||
|
||||
/**
|
||||
* License manager
|
||||
*/
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static function instance(): Plugin
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private constructor for singleton
|
||||
*/
|
||||
private function __construct()
|
||||
{
|
||||
$this->initTwig();
|
||||
$this->initComponents();
|
||||
$this->registerHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Twig environment
|
||||
*/
|
||||
private function initTwig(): void
|
||||
{
|
||||
$loader = new FilesystemLoader(WC_LICENSED_PRODUCT_PLUGIN_DIR . 'templates');
|
||||
$this->twig = new Environment($loader, [
|
||||
'cache' => WP_CONTENT_DIR . '/cache/wc-licensed-product/twig',
|
||||
'auto_reload' => WP_DEBUG,
|
||||
]);
|
||||
|
||||
// Add WordPress functions as Twig functions
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('__', function (string $text, string $domain = 'wc-licensed-product'): string {
|
||||
return __($text, $domain);
|
||||
}));
|
||||
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('esc_html', 'esc_html'));
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('esc_attr', 'esc_attr'));
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('esc_url', 'esc_url'));
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('wp_nonce_field', 'wp_nonce_field', ['is_safe' => ['html']]));
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('admin_url', 'admin_url'));
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('wc_get_endpoint_url', 'wc_get_endpoint_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize plugin components
|
||||
*/
|
||||
private function initComponents(): void
|
||||
{
|
||||
$this->licenseManager = new LicenseManager();
|
||||
|
||||
// Initialize controllers
|
||||
new LicensedProductType();
|
||||
new CheckoutController($this->licenseManager);
|
||||
new AccountController($this->twig, $this->licenseManager);
|
||||
new RestApiController($this->licenseManager);
|
||||
|
||||
if (is_admin()) {
|
||||
new AdminController($this->twig, $this->licenseManager);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register plugin hooks
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
// Generate license on order completion
|
||||
add_action('woocommerce_order_status_completed', [$this, 'onOrderCompleted']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order completion - generate licenses
|
||||
*/
|
||||
public function onOrderCompleted(int $orderId): void
|
||||
{
|
||||
$order = wc_get_order($orderId);
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
$domain = $order->get_meta('_licensed_product_domain');
|
||||
if ($domain) {
|
||||
$this->licenseManager->generateLicense(
|
||||
$orderId,
|
||||
$product->get_id(),
|
||||
$order->get_customer_id(),
|
||||
$domain
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Twig environment
|
||||
*/
|
||||
public function getTwig(): Environment
|
||||
{
|
||||
return $this->twig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license manager
|
||||
*/
|
||||
public function getLicenseManager(): LicenseManager
|
||||
{
|
||||
return $this->licenseManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Twig template
|
||||
*/
|
||||
public function render(string $template, array $context = []): string
|
||||
{
|
||||
return $this->twig->render($template, $context);
|
||||
}
|
||||
}
|
||||
103
src/Product/LicensedProduct.php
Normal file
103
src/Product/LicensedProduct.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* Licensed Product Class
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Product
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Product;
|
||||
|
||||
use WC_Product;
|
||||
|
||||
/**
|
||||
* Licensed Product type extending WooCommerce Product
|
||||
*/
|
||||
class LicensedProduct extends WC_Product
|
||||
{
|
||||
/**
|
||||
* Product type
|
||||
*/
|
||||
protected $product_type = 'licensed';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct($product = 0)
|
||||
{
|
||||
parent::__construct($product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product type
|
||||
*/
|
||||
public function get_type(): string
|
||||
{
|
||||
return 'licensed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed products are always virtual
|
||||
*/
|
||||
public function is_virtual(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed products are purchasable
|
||||
*/
|
||||
public function is_purchasable(): bool
|
||||
{
|
||||
return $this->exists() && $this->get_price() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get max activations for this product
|
||||
*/
|
||||
public function get_max_activations(): int
|
||||
{
|
||||
$value = $this->get_meta('_licensed_max_activations', true);
|
||||
return $value ? (int) $value : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validity days
|
||||
*/
|
||||
public function get_validity_days(): ?int
|
||||
{
|
||||
$value = $this->get_meta('_licensed_validity_days', true);
|
||||
return $value !== '' ? (int) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if license should be bound to major version
|
||||
*/
|
||||
public function is_bound_to_version(): bool
|
||||
{
|
||||
return $this->get_meta('_licensed_bind_to_version', true) === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current software version
|
||||
*/
|
||||
public function get_current_version(): string
|
||||
{
|
||||
return $this->get_meta('_licensed_current_version', true) ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get major version number from version string
|
||||
*/
|
||||
public function get_major_version(): int
|
||||
{
|
||||
$version = $this->get_current_version();
|
||||
if (empty($version)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$parts = explode('.', $version);
|
||||
return (int) ($parts[0] ?? 1);
|
||||
}
|
||||
}
|
||||
200
src/Product/LicensedProductType.php
Normal file
200
src/Product/LicensedProductType.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
/**
|
||||
* Licensed Product Type
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Product
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Product;
|
||||
|
||||
/**
|
||||
* Registers and handles the Licensed product type for WooCommerce
|
||||
*/
|
||||
final class LicensedProductType
|
||||
{
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->registerHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
// Register product type
|
||||
add_filter('product_type_selector', [$this, 'addProductType']);
|
||||
add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 2);
|
||||
|
||||
// Add product data tabs
|
||||
add_filter('woocommerce_product_data_tabs', [$this, 'addProductDataTab']);
|
||||
add_action('woocommerce_product_data_panels', [$this, 'addProductDataPanel']);
|
||||
|
||||
// Save product meta
|
||||
add_action('woocommerce_process_product_meta_licensed', [$this, 'saveProductMeta']);
|
||||
|
||||
// Show price and add to cart for licensed products
|
||||
add_action('woocommerce_licensed_add_to_cart', [$this, 'addToCartTemplate']);
|
||||
|
||||
// Make product virtual by default
|
||||
add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add product type to selector
|
||||
*/
|
||||
public function addProductType(array $types): array
|
||||
{
|
||||
$types['licensed'] = __('Licensed Product', 'wc-licensed-product');
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product class for licensed type
|
||||
*/
|
||||
public function getProductClass(string $className, string $productType): string
|
||||
{
|
||||
if ($productType === 'licensed') {
|
||||
return LicensedProduct::class;
|
||||
}
|
||||
return $className;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add product data tab for license settings
|
||||
*/
|
||||
public function addProductDataTab(array $tabs): array
|
||||
{
|
||||
$tabs['licensed_product'] = [
|
||||
'label' => __('License Settings', 'wc-licensed-product'),
|
||||
'target' => 'licensed_product_data',
|
||||
'class' => ['show_if_licensed'],
|
||||
'priority' => 21,
|
||||
];
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add product data panel content
|
||||
*/
|
||||
public function addProductDataPanel(): void
|
||||
{
|
||||
global $post;
|
||||
?>
|
||||
<div id="licensed_product_data" class="panel woocommerce_options_panel hidden">
|
||||
<div class="options_group">
|
||||
<?php
|
||||
woocommerce_wp_text_input([
|
||||
'id' => '_licensed_max_activations',
|
||||
'label' => __('Max Activations', 'wc-licensed-product'),
|
||||
'description' => __('Maximum number of domain activations per license. Default: 1', 'wc-licensed-product'),
|
||||
'desc_tip' => true,
|
||||
'type' => 'number',
|
||||
'custom_attributes' => [
|
||||
'min' => '1',
|
||||
'step' => '1',
|
||||
],
|
||||
'value' => get_post_meta($post->ID, '_licensed_max_activations', true) ?: '1',
|
||||
]);
|
||||
|
||||
woocommerce_wp_text_input([
|
||||
'id' => '_licensed_validity_days',
|
||||
'label' => __('License Validity (Days)', 'wc-licensed-product'),
|
||||
'description' => __('Number of days the license is valid. Leave empty for lifetime license.', 'wc-licensed-product'),
|
||||
'desc_tip' => true,
|
||||
'type' => 'number',
|
||||
'custom_attributes' => [
|
||||
'min' => '0',
|
||||
'step' => '1',
|
||||
],
|
||||
]);
|
||||
|
||||
woocommerce_wp_checkbox([
|
||||
'id' => '_licensed_bind_to_version',
|
||||
'label' => __('Bind to Major Version', 'wc-licensed-product'),
|
||||
'description' => __('If enabled, licenses are bound to the major version at purchase time.', 'wc-licensed-product'),
|
||||
]);
|
||||
|
||||
woocommerce_wp_text_input([
|
||||
'id' => '_licensed_current_version',
|
||||
'label' => __('Current Version', 'wc-licensed-product'),
|
||||
'description' => __('Current software version (e.g., 1.0.0)', 'wc-licensed-product'),
|
||||
'desc_tip' => true,
|
||||
'placeholder' => '1.0.0',
|
||||
]);
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function($) {
|
||||
// Show/hide panels based on product type
|
||||
$('select#product-type').change(function() {
|
||||
if ($(this).val() === 'licensed') {
|
||||
$('.show_if_licensed').show();
|
||||
$('.general_options').show();
|
||||
$('.pricing').show();
|
||||
} else {
|
||||
$('.show_if_licensed').hide();
|
||||
}
|
||||
}).change();
|
||||
|
||||
// Show general tab for licensed products
|
||||
$('#product-type').on('change', function() {
|
||||
if ($(this).val() === 'licensed') {
|
||||
$('.general_tab').show();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Save product meta
|
||||
*/
|
||||
public function saveProductMeta(int $postId): void
|
||||
{
|
||||
// Verify nonce is handled by WooCommerce
|
||||
$maxActivations = isset($_POST['_licensed_max_activations'])
|
||||
? absint($_POST['_licensed_max_activations'])
|
||||
: 1;
|
||||
update_post_meta($postId, '_licensed_max_activations', $maxActivations);
|
||||
|
||||
$validityDays = isset($_POST['_licensed_validity_days']) && $_POST['_licensed_validity_days'] !== ''
|
||||
? absint($_POST['_licensed_validity_days'])
|
||||
: '';
|
||||
update_post_meta($postId, '_licensed_validity_days', $validityDays);
|
||||
|
||||
$bindToVersion = isset($_POST['_licensed_bind_to_version']) ? 'yes' : 'no';
|
||||
update_post_meta($postId, '_licensed_bind_to_version', $bindToVersion);
|
||||
|
||||
$currentVersion = isset($_POST['_licensed_current_version'])
|
||||
? sanitize_text_field($_POST['_licensed_current_version'])
|
||||
: '';
|
||||
update_post_meta($postId, '_licensed_current_version', $currentVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to cart template for licensed products
|
||||
*/
|
||||
public function addToCartTemplate(): void
|
||||
{
|
||||
wc_get_template('single-product/add-to-cart/simple.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make licensed products virtual by default
|
||||
*/
|
||||
public function isVirtual(bool $isVirtual, \WC_Product $product): bool
|
||||
{
|
||||
if ($product->is_type('licensed')) {
|
||||
return true;
|
||||
}
|
||||
return $isVirtual;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user