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

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
# Linked wordpress core and plugin folder
wp-plugins
wp-core
vendor/

48
CHANGELOG.md Normal file
View File

@@ -0,0 +1,48 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.0.1] - 2024-01-21
### Added
- Initial plugin structure with WordPress Plugin API integration
- New WooCommerce product type "Licensed Product" for selling software licenses
- License key generation with format XXXX-XXXX-XXXX-XXXX on order completion
- Domain-based license validation (licenses bound to specific domains)
- REST API endpoints for license management:
- `POST /wp-json/wc-licensed-product/v1/validate` - Validate license for domain
- `POST /wp-json/wc-licensed-product/v1/status` - Check license status
- `POST /wp-json/wc-licensed-product/v1/activate` - Activate license on domain
- `POST /wp-json/wc-licensed-product/v1/deactivate` - Deactivate license
- Checkout domain field for licensed products
- Customer account page "Licenses" to view purchased licenses
- Admin interface for license management (WooCommerce > Licenses)
- License settings per product:
- Maximum activations per license
- License validity period (days or lifetime)
- Optional binding to major software version
- Current version tracking
- Custom database tables for licenses and product versions
- Twig template engine integration for views
- Full internationalization support (i18n)
- German (Switzerland, formal) translation (de_CH)
- WooCommerce HPOS compatibility
- Responsive frontend license table
### Technical
- PHP 8.3+ required
- WooCommerce 10.0+ required
- PSR-4 autoloading via Composer
- Twig 3.0 template engine
- WordPress REST API integration
- Custom WooCommerce product type extending WC_Product
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.1...HEAD
[0.0.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/releases/tag/v0.0.1

121
README.md Normal file
View File

@@ -0,0 +1,121 @@
# WC Licensed Product
A WooCommerce plugin to sell software products using license keys with domain-based validation.
## Description
WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, enabling you to sell software with automatically generated license keys. Licenses are bound to specific domains and can be validated through a REST API.
## Features
- **Licensed Product Type**: New WooCommerce product type for software sales
- **Automatic License Generation**: License keys generated on order completion
- **Domain Binding**: Licenses are bound to customer-specified domains
- **REST API**: Public endpoints for license validation and management
- **Customer Account**: Customers can view their licenses in My Account
- **Admin Management**: Full CRUD interface for license management
- **Version Binding**: Optional binding to major software versions
- **Expiration Support**: Set license validity periods or lifetime licenses
## Requirements
- WordPress 6.0 or higher
- WooCommerce 10.0 or higher
- PHP 8.3 or higher
## Installation
1. Upload the `wc-licensed-product` folder to `/wp-content/plugins/`
2. Activate the plugin through the 'Plugins' menu in WordPress
3. The plugin will create necessary database tables on activation
## Usage
### Creating a Licensed Product
1. Go to Products > Add New
2. Select "Licensed Product" from the product type dropdown
3. Configure the product price in the General tab
4. Set license options in the "License Settings" tab:
- **Max Activations**: Number of domains allowed per license
- **License Validity**: Days until expiration (empty = lifetime)
- **Bind to Major Version**: Lock license to current major version
- **Current Version**: Your software's current version
### Customer Checkout
When a customer purchases a licensed product, they must enter the domain where they will use the license during checkout.
### Viewing Licenses
- **Customers**: My Account > Licenses
- **Administrators**: WooCommerce > Licenses
## REST API
### Validate License
```http
POST /wp-json/wc-licensed-product/v1/validate
Content-Type: application/json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"domain": "example.com"
}
```
### Check Status
```http
POST /wp-json/wc-licensed-product/v1/status
Content-Type: application/json
{
"license_key": "XXXX-XXXX-XXXX-XXXX"
}
```
### Activate License
```http
POST /wp-json/wc-licensed-product/v1/activate
Content-Type: application/json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"domain": "newdomain.com"
}
```
### Deactivate License
```http
POST /wp-json/wc-licensed-product/v1/deactivate
Content-Type: application/json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"domain": "example.com"
}
```
## License Statuses
- **Active**: License is valid and usable
- **Inactive**: License has been deactivated
- **Expired**: License validity period has ended
- **Revoked**: License has been manually revoked by admin
## Support
For issues and feature requests, please visit:
<https://src.bundespruefstelle.ch/magdev/wc-licensed-product/issues>
## Author
Marco Graetsch
## License
GPL-2.0-or-later

91
assets/css/admin.css Normal file
View File

@@ -0,0 +1,91 @@
/**
* WC Licensed Product - Admin Styles
*
* @package Jeremias\WcLicensedProduct
*/
/* License Status Badges */
.license-status {
display: inline-block;
padding: 0.2em 0.5em;
font-size: 0.85em;
font-weight: 500;
line-height: 1.2;
border-radius: 3px;
}
.license-status-active {
background-color: #d4edda;
color: #155724;
}
.license-status-inactive {
background-color: #fff3cd;
color: #856404;
}
.license-status-expired {
background-color: #f8d7da;
color: #721c24;
}
.license-status-revoked {
background-color: #d6d8db;
color: #383d41;
}
/* License Table */
.wp-list-table code {
font-family: monospace;
background-color: #f0f0f0;
padding: 0.15em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
/* License Product Tab */
#woocommerce-product-data .show_if_licensed {
display: block !important;
}
#woocommerce-product-data .hide_if_licensed {
display: none !important;
}
/* Action Buttons */
.wp-list-table .button-link-delete {
color: #a00;
}
.wp-list-table .button-link-delete:hover {
color: #dc3232;
}
/* Pagination */
.tablenav-pages .pagination-links {
display: flex;
align-items: center;
gap: 0.5em;
}
.tablenav-pages .paging-input {
margin: 0 0.5em;
}
/* Orders Column */
.column-license {
width: 15%;
}
.column-license .dashicons {
vertical-align: middle;
margin-right: 0.3em;
}
.column-license .dashicons-warning {
color: #dba617;
}
.column-license .dashicons-admin-network {
color: #2271b1;
}

124
assets/css/frontend.css Normal file
View File

@@ -0,0 +1,124 @@
/**
* WC Licensed Product - Frontend Styles
*
* @package Jeremias\WcLicensedProduct
*/
/* License Status Badges */
.license-status {
display: inline-block;
padding: 0.25em 0.6em;
font-size: 0.85em;
font-weight: 600;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.25rem;
}
.license-status-active {
background-color: #d4edda;
color: #155724;
}
.license-status-inactive {
background-color: #fff3cd;
color: #856404;
}
.license-status-expired {
background-color: #f8d7da;
color: #721c24;
}
.license-status-revoked {
background-color: #d6d8db;
color: #383d41;
}
/* License Table */
.woocommerce-licenses-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1em;
}
.woocommerce-licenses-table th,
.woocommerce-licenses-table td {
padding: 0.75em;
text-align: left;
border-bottom: 1px solid #e5e5e5;
}
.woocommerce-licenses-table th {
font-weight: 600;
background-color: #f8f8f8;
}
.woocommerce-licenses-table code {
font-family: monospace;
background-color: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
/* Domain Field */
#licensed-product-domain-field {
margin-top: 2em;
padding: 1.5em;
background-color: #f8f9fa;
border: 1px solid #e5e5e5;
border-radius: 4px;
}
#licensed-product-domain-field h3 {
margin-top: 0;
margin-bottom: 1em;
font-size: 1.1em;
}
#licensed-product-domain-field .description {
display: block;
margin-top: 0.5em;
font-size: 0.9em;
color: #666;
}
/* Responsive */
@media screen and (max-width: 768px) {
.woocommerce-licenses-table,
.woocommerce-licenses-table thead,
.woocommerce-licenses-table tbody,
.woocommerce-licenses-table th,
.woocommerce-licenses-table td,
.woocommerce-licenses-table tr {
display: block;
}
.woocommerce-licenses-table thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
.woocommerce-licenses-table tr {
border: 1px solid #e5e5e5;
margin-bottom: 1em;
}
.woocommerce-licenses-table td {
border: none;
position: relative;
padding-left: 50%;
}
.woocommerce-licenses-table td:before {
content: attr(data-title);
position: absolute;
left: 0.75em;
width: 45%;
font-weight: 600;
}
}

31
composer.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "magdev/wc-licensed-product",
"description": "WooCommerce plugin to sell software products using license keys",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"authors": [
{
"name": "Marco Graetsch",
"email": "magdev3.0@gmail.com",
"homepage": "https://src.bundespruefstelle.ch/magdev"
}
],
"require": {
"php": ">=8.3.0",
"twig/twig": "^3.0"
},
"autoload": {
"psr-4": {
"Jeremias\\WcLicensedProduct\\": "src/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true,
"platform": {
"php": "8.3.0"
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

338
composer.lock generated Normal file
View File

@@ -0,0 +1,338 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3b63b77b19677953867f471c141fee05",
"packages": [
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"files": [
"function.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"provide": {
"ext-ctype": "*"
},
"suggest": {
"ext-ctype": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Ctype\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Gert de Pagter",
"email": "BackEndTea@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for ctype functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"ctype",
"polyfill",
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": ">=7.2"
},
"provide": {
"ext-mbstring": "*"
},
"suggest": {
"ext-mbstring": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Mbstring\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for the Mbstring extension",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"mbstring",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-12-23T08:48:59+00:00"
},
{
"name": "twig/twig",
"version": "v3.22.2",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2",
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2",
"shasum": ""
},
"require": {
"php": ">=8.1.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.3"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
"psr/container": "^1.0|^2.0",
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
},
"type": "library",
"autoload": {
"files": [
"src/Resources/core.php",
"src/Resources/debug.php",
"src/Resources/escaper.php",
"src/Resources/string_loader.php"
],
"psr-4": {
"Twig\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
},
{
"name": "Twig Team",
"role": "Contributors"
},
{
"name": "Armin Ronacher",
"email": "armin.ronacher@active-4.com",
"role": "Project Founder"
}
],
"description": "Twig, the flexible, fast, and secure template language for PHP",
"homepage": "https://twig.symfony.com",
"keywords": [
"templating"
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.22.2"
},
"funding": [
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
"type": "tidelift"
}
],
"time": "2025-12-14T11:28:47+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": ">=8.3.0"
},
"platform-dev": {},
"platform-overrides": {
"php": "8.3.0"
},
"plugin-api-version": "2.6.0"
}

View File

@@ -0,0 +1,256 @@
# German (Switzerland, formal) translation for WC Licensed Product
# Copyright (C) 2024 Marco Graetsch
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: WC Licensed Product 0.0.1\n"
"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/issues\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"Last-Translator: Marco Graetsch <magdev3.0@gmail.com>\n"
"Language-Team: German (Switzerland) <de_CH@li.org>\n"
"Language: de_CH\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: wc-licensed-product.php
msgid "%s requires WooCommerce to be installed and active."
msgstr "%s benötigt WooCommerce als installierte und aktivierte Erweiterung."
#: wc-licensed-product.php
msgid "WC Licensed Product requires WooCommerce to be installed and active."
msgstr "WC Licensed Product benötigt WooCommerce als installierte und aktivierte Erweiterung."
#: src/Product/LicensedProductType.php
msgid "Licensed Product"
msgstr "Lizenziertes Produkt"
#: src/Product/LicensedProductType.php
msgid "License Settings"
msgstr "Lizenz-Einstellungen"
#: src/Product/LicensedProductType.php
msgid "Max Activations"
msgstr "Max. Aktivierungen"
#: src/Product/LicensedProductType.php
msgid "Maximum number of domain activations per license. Default: 1"
msgstr "Maximale Anzahl der Domain-Aktivierungen pro Lizenz. Standard: 1"
#: src/Product/LicensedProductType.php
msgid "License Validity (Days)"
msgstr "Lizenz-Gültigkeit (Tage)"
#: src/Product/LicensedProductType.php
msgid "Number of days the license is valid. Leave empty for lifetime license."
msgstr "Anzahl Tage, die die Lizenz gültig ist. Leer lassen für eine lebenslange Lizenz."
#: src/Product/LicensedProductType.php
msgid "Bind to Major Version"
msgstr "An Hauptversion binden"
#: src/Product/LicensedProductType.php
msgid "If enabled, licenses are bound to the major version at purchase time."
msgstr "Falls aktiviert, werden Lizenzen an die Hauptversion zum Kaufzeitpunkt gebunden."
#: src/Product/LicensedProductType.php
msgid "Current Version"
msgstr "Aktuelle Version"
#: src/Product/LicensedProductType.php
msgid "Current software version (e.g., 1.0.0)"
msgstr "Aktuelle Software-Version (z.B. 1.0.0)"
#: src/License/LicenseManager.php
msgid "License key not found."
msgstr "Lizenzschlüssel nicht gefunden."
#: src/License/LicenseManager.php
msgid "This license has been revoked."
msgstr "Diese Lizenz wurde widerrufen."
#: src/License/LicenseManager.php
msgid "This license has expired."
msgstr "Diese Lizenz ist abgelaufen."
#: src/License/LicenseManager.php
msgid "This license is inactive."
msgstr "Diese Lizenz ist inaktiv."
#: src/License/LicenseManager.php
msgid "This license is not valid for this domain."
msgstr "Diese Lizenz ist für diese Domain nicht gültig."
#: src/Api/RestApiController.php
msgid "This license is not valid."
msgstr "Diese Lizenz ist ungültig."
#: src/Api/RestApiController.php
msgid "License is already activated for this domain."
msgstr "Die Lizenz ist bereits für diese Domain aktiviert."
#: src/Api/RestApiController.php
msgid "Maximum number of activations reached."
msgstr "Maximale Anzahl der Aktivierungen erreicht."
#: src/Api/RestApiController.php
msgid "Failed to activate license."
msgstr "Lizenz konnte nicht aktiviert werden."
#: src/Api/RestApiController.php
msgid "License activated successfully."
msgstr "Lizenz erfolgreich aktiviert."
#: src/Api/RestApiController.php
msgid "License is not activated for this domain."
msgstr "Die Lizenz ist für diese Domain nicht aktiviert."
#: src/Api/RestApiController.php
msgid "Failed to deactivate license."
msgstr "Lizenz konnte nicht deaktiviert werden."
#: src/Api/RestApiController.php
msgid "License deactivated successfully."
msgstr "Lizenz erfolgreich deaktiviert."
#: src/Checkout/CheckoutController.php
msgid "License Domain"
msgstr "Lizenz-Domain"
#: src/Checkout/CheckoutController.php
msgid "Domain for License Activation"
msgstr "Domain für Lizenz-Aktivierung"
#: src/Checkout/CheckoutController.php
msgid "required"
msgstr "erforderlich"
#: src/Checkout/CheckoutController.php
msgid "example.com"
msgstr "beispiel.ch"
#: src/Checkout/CheckoutController.php
msgid "Enter the domain where you will use this license (without http:// or www)."
msgstr "Geben Sie die Domain ein, auf der Sie diese Lizenz verwenden möchten (ohne http:// oder www)."
#: src/Checkout/CheckoutController.php
msgid "Please enter a domain for your license activation."
msgstr "Bitte geben Sie eine Domain für Ihre Lizenz-Aktivierung ein."
#: src/Checkout/CheckoutController.php
msgid "Please enter a valid domain name."
msgstr "Bitte geben Sie einen gültigen Domain-Namen ein."
#: src/Checkout/CheckoutController.php
msgid "License Domain:"
msgstr "Lizenz-Domain:"
#: src/Frontend/AccountController.php
msgid "Please log in to view your licenses."
msgstr "Bitte melden Sie sich an, um Ihre Lizenzen zu sehen."
#: src/Frontend/AccountController.php
msgid "Licenses"
msgstr "Lizenzen"
#: src/Frontend/AccountController.php
msgid "Unknown Product"
msgstr "Unbekanntes Produkt"
#: src/Frontend/AccountController.php
msgid "You have no licenses yet."
msgstr "Sie haben noch keine Lizenzen."
#: src/Frontend/AccountController.php
msgid "License Key"
msgstr "Lizenzschlüssel"
#: src/Frontend/AccountController.php
msgid "Product"
msgstr "Produkt"
#: src/Frontend/AccountController.php
msgid "Domain"
msgstr "Domain"
#: src/Frontend/AccountController.php
msgid "Status"
msgstr "Status"
#: src/Frontend/AccountController.php
msgid "Expires"
msgstr "Läuft ab"
#: src/Frontend/AccountController.php
msgid "Never"
msgstr "Nie"
#: src/Admin/AdminController.php
msgid "Security check failed."
msgstr "Sicherheitsüberprüfung fehlgeschlagen."
#: src/Admin/AdminController.php
msgid "License updated successfully."
msgstr "Lizenz erfolgreich aktualisiert."
#: src/Admin/AdminController.php
msgid "License deleted successfully."
msgstr "Lizenz erfolgreich gelöscht."
#: src/Admin/AdminController.php
msgid "License revoked successfully."
msgstr "Lizenz erfolgreich widerrufen."
#: src/Admin/AdminController.php
msgid "Unknown"
msgstr "Unbekannt"
#: src/Admin/AdminController.php
msgid "Guest"
msgstr "Gast"
#: src/Admin/AdminController.php
msgid "Customer"
msgstr "Kunde"
#: src/Admin/AdminController.php
msgid "Actions"
msgstr "Aktionen"
#: src/Admin/AdminController.php
msgid "No licenses found."
msgstr "Keine Lizenzen gefunden."
#: src/Admin/AdminController.php
msgid "Are you sure?"
msgstr "Sind Sie sicher?"
#: src/Admin/AdminController.php
msgid "Revoke"
msgstr "Widerrufen"
#: src/Admin/AdminController.php
msgid "Are you sure you want to delete this license?"
msgstr "Sind Sie sicher, dass Sie diese Lizenz löschen möchten?"
#: src/Admin/AdminController.php
msgid "Delete"
msgstr "Löschen"
#: src/Admin/AdminController.php
msgid "License"
msgstr "Lizenz"
#: src/Admin/AdminController.php
msgid "No domain specified"
msgstr "Keine Domain angegeben"
#: src/Admin/AdminController.php
msgid "Total licenses:"
msgstr "Lizenzen insgesamt:"
#: src/Admin/AdminController.php
msgid "of"
msgstr "von"

View File

@@ -0,0 +1,253 @@
# Copyright (C) 2024 Marco Graetsch
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: WC Licensed Product 0.0.1\n"
"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/issues\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
#: wc-licensed-product.php
msgid "%s requires WooCommerce to be installed and active."
msgstr ""
#: wc-licensed-product.php
msgid "WC Licensed Product requires WooCommerce to be installed and active."
msgstr ""
#: src/Product/LicensedProductType.php
msgid "Licensed Product"
msgstr ""
#: src/Product/LicensedProductType.php
msgid "License Settings"
msgstr ""
#: src/Product/LicensedProductType.php
msgid "Max Activations"
msgstr ""
#: src/Product/LicensedProductType.php
msgid "Maximum number of domain activations per license. Default: 1"
msgstr ""
#: src/Product/LicensedProductType.php
msgid "License Validity (Days)"
msgstr ""
#: src/Product/LicensedProductType.php
msgid "Number of days the license is valid. Leave empty for lifetime license."
msgstr ""
#: src/Product/LicensedProductType.php
msgid "Bind to Major Version"
msgstr ""
#: src/Product/LicensedProductType.php
msgid "If enabled, licenses are bound to the major version at purchase time."
msgstr ""
#: src/Product/LicensedProductType.php
msgid "Current Version"
msgstr ""
#: src/Product/LicensedProductType.php
msgid "Current software version (e.g., 1.0.0)"
msgstr ""
#: src/License/LicenseManager.php
msgid "License key not found."
msgstr ""
#: src/License/LicenseManager.php
msgid "This license has been revoked."
msgstr ""
#: src/License/LicenseManager.php
msgid "This license has expired."
msgstr ""
#: src/License/LicenseManager.php
msgid "This license is inactive."
msgstr ""
#: src/License/LicenseManager.php
msgid "This license is not valid for this domain."
msgstr ""
#: src/Api/RestApiController.php
msgid "This license is not valid."
msgstr ""
#: src/Api/RestApiController.php
msgid "License is already activated for this domain."
msgstr ""
#: src/Api/RestApiController.php
msgid "Maximum number of activations reached."
msgstr ""
#: src/Api/RestApiController.php
msgid "Failed to activate license."
msgstr ""
#: src/Api/RestApiController.php
msgid "License activated successfully."
msgstr ""
#: src/Api/RestApiController.php
msgid "License is not activated for this domain."
msgstr ""
#: src/Api/RestApiController.php
msgid "Failed to deactivate license."
msgstr ""
#: src/Api/RestApiController.php
msgid "License deactivated successfully."
msgstr ""
#: src/Checkout/CheckoutController.php
msgid "License Domain"
msgstr ""
#: src/Checkout/CheckoutController.php
msgid "Domain for License Activation"
msgstr ""
#: src/Checkout/CheckoutController.php
msgid "required"
msgstr ""
#: src/Checkout/CheckoutController.php
msgid "example.com"
msgstr ""
#: src/Checkout/CheckoutController.php
msgid "Enter the domain where you will use this license (without http:// or www)."
msgstr ""
#: src/Checkout/CheckoutController.php
msgid "Please enter a domain for your license activation."
msgstr ""
#: src/Checkout/CheckoutController.php
msgid "Please enter a valid domain name."
msgstr ""
#: src/Checkout/CheckoutController.php
msgid "License Domain:"
msgstr ""
#: src/Frontend/AccountController.php
msgid "Please log in to view your licenses."
msgstr ""
#: src/Frontend/AccountController.php
msgid "Licenses"
msgstr ""
#: src/Frontend/AccountController.php
msgid "Unknown Product"
msgstr ""
#: src/Frontend/AccountController.php
msgid "You have no licenses yet."
msgstr ""
#: src/Frontend/AccountController.php
msgid "License Key"
msgstr ""
#: src/Frontend/AccountController.php
msgid "Product"
msgstr ""
#: src/Frontend/AccountController.php
msgid "Domain"
msgstr ""
#: src/Frontend/AccountController.php
msgid "Status"
msgstr ""
#: src/Frontend/AccountController.php
msgid "Expires"
msgstr ""
#: src/Frontend/AccountController.php
msgid "Never"
msgstr ""
#: src/Admin/AdminController.php
msgid "Security check failed."
msgstr ""
#: src/Admin/AdminController.php
msgid "License updated successfully."
msgstr ""
#: src/Admin/AdminController.php
msgid "License deleted successfully."
msgstr ""
#: src/Admin/AdminController.php
msgid "License revoked successfully."
msgstr ""
#: src/Admin/AdminController.php
msgid "Unknown"
msgstr ""
#: src/Admin/AdminController.php
msgid "Guest"
msgstr ""
#: src/Admin/AdminController.php
msgid "Customer"
msgstr ""
#: src/Admin/AdminController.php
msgid "Actions"
msgstr ""
#: src/Admin/AdminController.php
msgid "No licenses found."
msgstr ""
#: src/Admin/AdminController.php
msgid "Are you sure?"
msgstr ""
#: src/Admin/AdminController.php
msgid "Revoke"
msgstr ""
#: src/Admin/AdminController.php
msgid "Are you sure you want to delete this license?"
msgstr ""
#: src/Admin/AdminController.php
msgid "Delete"
msgstr ""
#: src/Admin/AdminController.php
msgid "License"
msgstr ""
#: src/Admin/AdminController.php
msgid "No domain specified"
msgstr ""
#: src/Admin/AdminController.php
msgid "Total licenses:"
msgstr ""
#: src/Admin/AdminController.php
msgid "of"
msgstr ""

View File

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

View File

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

View File

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

View File

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

138
src/Installer.php Normal file
View File

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

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

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

View File

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

163
src/Plugin.php Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
<div class="wrap">
<h1>{{ __('Licenses') }}</h1>
{% for notice in notices %}
<div class="notice notice-{{ notice.type }} is-dismissible">
<p>{{ esc_html(notice.message) }}</p>
</div>
{% endfor %}
<p class="description">
{{ __('Total licenses:') }} <strong>{{ total_licenses }}</strong>
</p>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th>{{ __('License Key') }}</th>
<th>{{ __('Product') }}</th>
<th>{{ __('Customer') }}</th>
<th>{{ __('Domain') }}</th>
<th>{{ __('Status') }}</th>
<th>{{ __('Expires') }}</th>
<th>{{ __('Actions') }}</th>
</tr>
</thead>
<tbody>
{% if licenses is empty %}
<tr>
<td colspan="7">{{ __('No licenses found.') }}</td>
</tr>
{% else %}
{% for item in licenses %}
<tr>
<td><code>{{ item.license.licenseKey }}</code></td>
<td>
{% if item.product_edit_url %}
<a href="{{ esc_url(item.product_edit_url) }}">{{ esc_html(item.product_name) }}</a>
{% else %}
{{ esc_html(item.product_name) }}
{% endif %}
</td>
<td>
{{ esc_html(item.customer_name) }}
{% if item.customer_email %}
<br><small>{{ esc_html(item.customer_email) }}</small>
{% endif %}
</td>
<td>{{ esc_html(item.license.domain) }}</td>
<td>
<span class="license-status license-status-{{ item.license.status }}">
{{ item.license.status|capitalize }}
</span>
</td>
<td>
{% if item.license.expiresAt %}
{{ item.license.expiresAt|date('Y-m-d') }}
{% else %}
{{ __('Never') }}
{% endif %}
</td>
<td>
{% if item.license.status != 'revoked' %}
<a href="{{ admin_url ~ '?page=wc-licenses&action=revoke&license_id=' ~ item.license.id ~ '&_wpnonce=' }}"
class="button button-small"
onclick="return confirm('{{ __('Are you sure?') }}')">
{{ __('Revoke') }}
</a>
{% endif %}
<a href="{{ admin_url ~ '?page=wc-licenses&action=delete&license_id=' ~ item.license.id ~ '&_wpnonce=' }}"
class="button button-small button-link-delete"
onclick="return confirm('{{ __('Are you sure you want to delete this license?') }}')">
{{ __('Delete') }}
</a>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
{% if total_pages > 1 %}
<div class="tablenav bottom">
<div class="tablenav-pages">
<span class="pagination-links">
{% if current_page > 1 %}
<a class="prev-page button" href="{{ admin_url ~ '&paged=' ~ (current_page - 1) }}">
<span aria-hidden="true"></span>
</a>
{% endif %}
<span class="paging-input">
{{ current_page }} {{ __('of') }} {{ total_pages }}
</span>
{% if current_page < total_pages %}
<a class="next-page button" href="{{ admin_url ~ '&paged=' ~ (current_page + 1) }}">
<span aria-hidden="true"></span>
</a>
{% endif %}
</span>
</div>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,46 @@
{% if not has_licenses %}
<p>{{ __('You have no licenses yet.') }}</p>
{% else %}
<table class="woocommerce-licenses-table shop_table shop_table_responsive">
<thead>
<tr>
<th>{{ __('License Key') }}</th>
<th>{{ __('Product') }}</th>
<th>{{ __('Domain') }}</th>
<th>{{ __('Status') }}</th>
<th>{{ __('Expires') }}</th>
</tr>
</thead>
<tbody>
{% for item in licenses %}
<tr>
<td data-title="{{ __('License Key') }}">
<code>{{ item.license.licenseKey }}</code>
</td>
<td data-title="{{ __('Product') }}">
{% if item.product_url %}
<a href="{{ esc_url(item.product_url) }}">{{ esc_html(item.product_name) }}</a>
{% else %}
{{ esc_html(item.product_name) }}
{% endif %}
</td>
<td data-title="{{ __('Domain') }}">
{{ esc_html(item.license.domain) }}
</td>
<td data-title="{{ __('Status') }}">
<span class="license-status license-status-{{ item.license.status }}">
{{ item.license.status|capitalize }}
</span>
</td>
<td data-title="{{ __('Expires') }}">
{% if item.license.expiresAt %}
{{ item.license.expiresAt|date('Y-m-d') }}
{% else %}
{{ __('Never') }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

113
wc-licensed-product.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
/**
* Plugin Name: WC Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
* Version: 0.0.1
* Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: wc-licensed-product
* Domain Path: /languages
* Requires at least: 6.0
* Requires PHP: 8.3
* WC requires at least: 10.0
* WC tested up to: 10.0
*
* @package Jeremias\WcLicensedProduct
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct;
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
// Plugin constants
define('WC_LICENSED_PRODUCT_VERSION', '0.0.1');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_BASENAME', plugin_basename(__FILE__));
// Load Composer autoloader
if (file_exists(WC_LICENSED_PRODUCT_PLUGIN_DIR . 'vendor/autoload.php')) {
require_once WC_LICENSED_PRODUCT_PLUGIN_DIR . 'vendor/autoload.php';
}
/**
* Check if WooCommerce is active
*/
function wc_licensed_product_is_woocommerce_active(): bool
{
return class_exists('WooCommerce');
}
/**
* Admin notice for missing WooCommerce
*/
function wc_licensed_product_woocommerce_missing_notice(): void
{
?>
<div class="error">
<p>
<?php
printf(
/* translators: %s: WooCommerce plugin name */
esc_html__('%s requires WooCommerce to be installed and active.', 'wc-licensed-product'),
'<strong>WC Licensed Product</strong>'
);
?>
</p>
</div>
<?php
}
/**
* Initialize the plugin
*/
function wc_licensed_product_init(): void
{
// Check for WooCommerce
if (!wc_licensed_product_is_woocommerce_active()) {
add_action('admin_notices', __NAMESPACE__ . '\\wc_licensed_product_woocommerce_missing_notice');
return;
}
// Load text domain
load_plugin_textdomain(
'wc-licensed-product',
false,
dirname(WC_LICENSED_PRODUCT_PLUGIN_BASENAME) . '/languages'
);
// Initialize the plugin
Plugin::instance();
}
// Hook into plugins_loaded to ensure WooCommerce is loaded first
add_action('plugins_loaded', __NAMESPACE__ . '\\wc_licensed_product_init');
// Register activation hook
register_activation_hook(__FILE__, function (): void {
if (!wc_licensed_product_is_woocommerce_active()) {
deactivate_plugins(WC_LICENSED_PRODUCT_PLUGIN_BASENAME);
wp_die(
esc_html__('WC Licensed Product requires WooCommerce to be installed and active.', 'wc-licensed-product'),
'Plugin Activation Error',
['back_link' => true]
);
}
// Run installation
Installer::activate();
});
// Register deactivation hook
register_deactivation_hook(__FILE__, function (): void {
Installer::deactivate();
});