Implement version 0.0.1 - Licensed Product type for WooCommerce

Add complete plugin infrastructure for selling software with license keys:

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

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

View File

@@ -0,0 +1,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;
}
}