commit 956df726dcd0741cf533d92850696fec7169fd08
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 29 Oct 2025 21:40:15 +0100
initial import
Diffstat:
18 files changed, 3289 insertions(+), 0 deletions(-)
diff --git a/README.md b/README.md
@@ -0,0 +1,164 @@
+# GNU Taler Turnstile for WordPress
+
+A WordPress plugin that adds price fields to posts and requires payment via GNU Taler for access.
+
+## Description
+
+GNU Taler Turnstile enables content creators to monetize their WordPress content using the GNU Taler payment system. The plugin allows you to:
+
+- Set prices for individual posts/pages
+- Create price categories with different pricing tiers
+- Support multiple currencies
+- Offer subscription-based access
+- Configure payment through GNU Taler merchant backend
+
+## Installation
+
+1. Download `qrcode.min.js` from https://github.com/davidshimjs/qrcodejs and place it in `assets/js/qrcode.min.js`
+2. Upload the `taler-turnstile` folder to the `/wp-content/plugins/` directory
+3. Activate the plugin through the 'Plugins' menu in WordPress
+4. Configure your GNU Taler merchant backend in Settings > Taler Turnstile
+5. Create price categories under Taler Prices
+
+## Configuration
+
+### Basic Settings
+
+1. Navigate to **Settings > Taler Turnstile**
+2. Select which post types should support paid content
+3. Configure your payment backend URL (must end with `/`)
+4. Enter your access token (must start with `secret-token:`)
+5. Optionally enable "Disable Turnstile on Error" for graceful degradation
+6. Save settings - this will automatically add price category fields to selected post types
+
+### Subscription Prices
+
+After configuring the basic settings:
+
+1. Navigate to **Settings > Taler Subscriptions**
+2. Set prices for each subscription type in different currencies
+3. Leave fields empty to prevent purchasing that subscription with that currency
+4. Save subscription prices
+
+### Price Categories
+
+1. Navigate to **Taler Prices**
+2. Click "Add New" to create a price category
+3. Set prices for different subscription types and currencies
+4. Save the category
+5. Assign price categories to individual posts/pages via the meta box in the editor
+
+## Requirements
+
+- WordPress 5.0 or higher
+- PHP 7.4 or higher
+- GNU Taler merchant backend instance
+
+## File Structure
+
+```
+taler-turnstile/
+├── taler-turnstile.php # Main plugin file
+├── includes/
+│ ├── class-taler-merchant-api.php
+│ ├── class-price-category.php
+│ ├── class-field-manager.php
+│ └── class-content-filter.php # Paywall logic
+├── admin/
+│ ├── class-admin-settings.php
+│ ├── class-price-category-admin.php
+│ └── class-subscription-prices-admin.php
+├── assets/
+│ ├── css/
+│ │ ├── admin.css
+│ │ └── frontend.css # Paywall styles
+│ └── js/
+│ ├── admin.js
+│ ├── payment-button.js # QR code & polling
+│ ├── qrcode.min.js # (download separately)
+│ └── QRCODE-README.md
+└── README.md
+```
+
+## Features
+
+- **Content Paywall**: Automatically restricts access to paid content, showing excerpt + payment button
+- **QR Code Payment**: Generates QR codes for easy mobile wallet payments
+- **Payment Polling**: Real-time payment status checking with automatic page reload on completion
+- **Session Management**: Tracks paid access and active subscriptions per visitor session
+- **Flexible Pricing**: Set prices per subscription type and currency
+- **Multiple Currencies**: Support for EUR, USD, CHF, and more (fetched from backend)
+- **Subscription Support**: Offer subscription-based access with token families
+- **Subscription Pricing**: Configure prices for purchasing subscriptions
+- **Zero-Price Subscriptions**: Automatically grant access for subscriptions with zero price
+- **Post Type Support**: Enable paid content for any public post type
+- **Dynamic Field Management**: Automatically adds/removes price category fields when post types are enabled/disabled
+- **Meta Box Integration**: Easy price category selection in the post editor
+- **Error Handling**: Graceful degradation when backend is unavailable
+- **Cache Control**: Automatically disables caching for protected content
+- **WordPress Standards**: Follows WordPress coding standards and best practices
+- **Caching**: Efficient caching of backend data using WordPress transients
+
+## How It Works
+
+### For Visitors
+
+1. **View Protected Content**: When a visitor views a post with a price category assigned, they see:
+ - An excerpt/teaser of the content
+ - A payment button with QR code
+ - Payment options for different currencies and subscriptions
+
+2. **Make Payment**: The visitor can:
+ - Scan the QR code with their Taler wallet app
+ - Click the payment button to open their wallet
+ - Choose their preferred payment option (currency and subscription type)
+
+3. **Automatic Access**: Once payment is completed:
+ - The page automatically refreshes (via polling)
+ - Full content is displayed
+ - Access is stored in the session
+ - If a subscription was purchased, it's tracked for future visits
+
+### For Administrators
+
+1. **Configure Backend**: Set up the GNU Taler merchant backend connection
+2. **Set Subscription Prices**: Configure how much subscriptions cost to purchase
+3. **Create Price Categories**: Define pricing tiers for content
+4. **Assign Categories**: Select price categories when editing posts
+5. **Publish**: Protected content automatically shows the paywall
+
+### Session Tracking
+
+The plugin tracks:
+- **Paid Access**: Which posts the visitor has paid for in this session
+- **Active Subscriptions**: Which subscriptions are active and when they expire
+- **Pending Orders**: Orders that haven't been paid yet (to avoid creating duplicates)
+
+### Subscription Logic
+
+- If a subscription reduces the price to **zero**, full access is granted immediately
+- If a subscription provides a **discount**, the visitor pays the reduced price
+- Subscriptions can be **purchased** alongside content access
+- Subscription expiration is tracked per-session
+
+### Hooks and Filters
+
+The plugin provides several hooks for customization:
+
+- `taler_turnstile_enabled_post_types` - Filter enabled post types
+- `taler_turnstile_payment_choices` - Modify payment choices
+- `taler_turnstile_currencies` - Add or modify supported currencies
+
+## Support
+
+For issues and questions:
+- GNU Taler: https://taler.net
+- Documentation: https://docs.taler.net
+
+## License
+
+This plugin is licensed under GPL v3 or later.
+
+## Credits
+
+Based on the GNU Taler Turnstile module for Drupal.
diff --git a/WORKFLOW.md b/WORKFLOW.md
@@ -0,0 +1,250 @@
+# GNU Taler Turnstile Workflow
+
+This document explains the complete workflow of the Taler Turnstile WordPress plugin.
+
+## Setup Phase
+
+### 1. Plugin Installation
+- Admin uploads and activates the plugin
+- Default options are created (post type = 'post', grant_access_on_error = false)
+
+### 2. Backend Configuration
+**Settings > Taler Turnstile**
+- Admin enters payment backend URL (must end with `/`)
+- Admin enters access token (must start with `secret-token:`)
+- Settings are validated against the actual backend
+- Backend connection is tested via HTTP requests
+
+### 3. Post Type Selection
+- Admin selects which post types should support paid content
+- When enabled: price category meta field is automatically added
+- When disabled: price category meta field is automatically removed
+- Field appears as a meta box in the post editor sidebar
+
+### 4. Subscription Price Configuration
+**Settings > Taler Subscriptions**
+- Page only accessible after backend is configured
+- Lists all subscription types from the backend
+- Admin sets purchase price for each subscription in each currency
+- Empty price = subscription cannot be purchased in that currency
+
+### 5. Price Category Creation
+**Taler Prices > Add New**
+- Admin creates named price categories (e.g., "Premium", "Standard")
+- For each subscription type and currency combination, set the access price
+- Empty price = cannot pay with that combination
+- Zero price = subscription grants full access without payment
+
+## Content Publishing Phase
+
+### 6. Content Creation
+- Editor creates/edits a post of an enabled post type
+- In the sidebar meta box, selects a price category
+- Saves the post
+- The price category ID is stored as post meta
+
+## Visitor Access Phase
+
+### 7. Initial Page View
+
+When a visitor views a protected post:
+
+```
+1. WordPress loads the post
+2. the_content filter is triggered
+3. Taler_Content_Filter::filter_content() runs
+
+ Checks performed:
+ - Is this a singular post view? (not archive/search)
+ - Is this post type enabled?
+ - Does the post have a price category?
+ - Does visitor have a subscription granting zero-price access?
+ - Does visitor's session already have access?
+
+ If all checks pass → show full content
+ If any check fails → proceed to paywall logic
+```
+
+### 8. Order Management
+
+**Check for existing order:**
+```
+- Look in session: $_SESSION['taler_turnstile_node_orders'][$post_id]
+- If found:
+ - Query backend for order status
+ - If paid → grant session access, show full content
+ - If expired → ignore, create new order
+ - If still valid → reuse existing order
+- If not found or invalid:
+ - Create new order via backend API
+ - Store order info in session
+```
+
+**Order Creation:**
+```
+POST /private/orders
+{
+ "order": {
+ "version": 1,
+ "choices": [...], // From price category
+ "summary": "Access to: Post Title",
+ "fulfillment_url": "https://example.com/post-slug/",
+ "pay_deadline": {...}
+ },
+ "session_id": "hashed-session-id",
+ "create_token": false
+}
+
+Response: { "order_id": "..." }
+```
+
+### 9. Paywall Display
+
+Content is replaced with:
+```html
+<div class="taler-turnstile-paywall">
+ <!-- Post excerpt -->
+ <div class="taler-turnstile-excerpt">...</div>
+
+ <!-- Payment interface -->
+ <div class="taler-payment-container">
+ <!-- QR Code section -->
+ <div class="taler-qr-section">
+ <div class="taler-turnstile-qr-code-container"
+ data-payment-url="..."
+ data-session-id="...">
+ <!-- QR code generated by JavaScript -->
+ </div>
+ </div>
+
+ <!-- Button section -->
+ <div class="taler-button-section">
+ <a href="taler://pay/..." class="taler-pay-button">
+ Pay with GNU Taler
+ </a>
+ </div>
+ </div>
+</div>
+```
+
+### 10. QR Code Generation
+
+**Client-side (payment-button.js):**
+```javascript
+1. Find all .taler-turnstile-qr-code-container elements
+2. Extract payment-url and session-id from data attributes
+3. Convert HTTP URL to Taler URI format:
+ - https://backend/orders/ABC → taler://pay/backend/ABC/session-id
+ - http://backend/orders/ABC → taler+http://pay/backend/ABC/session-id
+4. Generate QR code using QRCode.js
+5. Update button href to use Taler URI
+```
+
+### 11. Payment Polling
+
+**Automatic polling starts:**
+```javascript
+function pollPaymentStatus(paymentUrl, sessionId) {
+ // Long-poll with 30-second timeout
+ GET paymentUrl?timeout_ms=30000&session_id=sessionId
+
+ Responses:
+ - 200/202: Payment complete → reload page
+ - 402: Payment pending → continue polling
+ - Timeout: Network timeout → retry polling
+ - Error: Wait 5 seconds → retry polling
+}
+```
+
+**Polling prevents:**
+- Rapid loops (enforces minimum delay between requests)
+- Server hammering (uses long-polling with timeout_ms)
+- Infinite errors (retries with backoff on non-402 errors)
+
+### 12. Payment Completion
+
+**When visitor pays via wallet:**
+```
+1. Taler wallet communicates with backend
+2. Backend marks order as paid
+3. Backend may issue subscription token
+4. Next poll request returns 200 OK
+5. JavaScript detects 200 and reloads page
+```
+
+**On page reload:**
+```
+1. filter_content() runs again
+2. Finds existing order in session
+3. Checks order status with backend
+4. Backend returns: paid=true, subscription info
+5. Plugin grants session access: $_SESSION['taler_turnstile_access'][$post_id] = true
+6. If subscription purchased: $_SESSION['taler_turnstile_subscriptions'][$slug] = expiration
+7. Full content is displayed
+```
+
+## Session State Management
+
+### Access Tracking
+```php
+$_SESSION['taler_turnstile_access'] = [
+ 123 => true, // Post ID 123 has been paid for
+ 456 => true, // Post ID 456 has been paid for
+];
+```
+
+### Subscription Tracking
+```php
+$_SESSION['taler_turnstile_subscriptions'] = [
+ 'premium' => 1735689600, // Expires at Unix timestamp
+ 'basic' => 1733097600,
+];
+```
+
+### Order Tracking
+```php
+$_SESSION['taler_turnstile_node_orders'] = [
+ 123 => [
+ 'order_id' => 'ABC123',
+ 'payment_url' => 'https://...',
+ 'session_id' => 'hashed-id',
+ 'order_expiration' => 1733011200,
+ 'paid' => false,
+ ],
+];
+```
+
+## Error Handling
+
+### Backend Unavailable
+```
+If grant_access_on_error = true:
+ - Show full content (fail open)
+ - Log warning
+
+If grant_access_on_error = false:
+ - Show error message (fail closed)
+ - Do not show content
+```
+
+### Order Creation Fails
+```
+- Check grant_access_on_error setting
+- Either show error or grant access
+- Log detailed error with HTTP status
+```
+
+### Payment Polling Errors
+```
+- Non-402 HTTP errors: Wait 5 seconds, retry
+- Network timeouts: Wait for full timeout period, retry
+- 402 responses: Continue polling immediately
+- Never give up polling (visitor may pay at any time)
+```
+
+## Security Considerations
+
+### Session Security
+- Session IDs are hashed (SHA-256) before sending to backend
+- Backend cannot correlate sessions across sites
+- Session data i
+\ No newline at end of file
diff --git a/admin/class-admin-settings.php b/admin/class-admin-settings.php
@@ -0,0 +1,347 @@
+<?php
+/**
+ * Admin Settings Page
+ *
+ * Handles the main plugin settings page.
+ */
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class Taler_Turnstile_Admin_Settings {
+
+ public function __construct() {
+ add_action('admin_init', array($this, 'register_settings'));
+ }
+
+ public function register_settings() {
+ // Register settings
+ register_setting('taler_turnstile_settings', 'taler_turnstile_enabled_post_types', array(
+ 'type' => 'array',
+ 'sanitize_callback' => array($this, 'sanitize_post_types')
+ ));
+
+ register_setting('taler_turnstile_settings', 'taler_turnstile_payment_backend_url', array(
+ 'type' => 'string',
+ 'sanitize_callback' => array($this, 'sanitize_backend_url')
+ ));
+
+ register_setting('taler_turnstile_settings', 'taler_turnstile_access_token', array(
+ 'type' => 'string',
+ 'sanitize_callback' => array($this, 'sanitize_access_token')
+ ));
+
+ register_setting('taler_turnstile_settings', 'taler_turnstile_grant_access_on_error', array(
+ 'type' => 'boolean'
+ ));
+
+ register_setting('taler_turnstile_settings', 'taler_turnstile_subscription_prices', array(
+ 'type' => 'array',
+ 'default' => array()
+ ));
+
+ // Add settings sections
+ add_settings_section(
+ 'taler_turnstile_main_section',
+ __('Basic Settings', 'taler-turnstile'),
+ array($this, 'main_section_callback'),
+ 'taler_turnstile_settings'
+ );
+
+ add_settings_section(
+ 'taler_turnstile_backend_section',
+ __('Payment Backend Configuration', 'taler-turnstile'),
+ array($this, 'backend_section_callback'),
+ 'taler_turnstile_settings'
+ );
+
+ // Add settings fields
+ add_settings_field(
+ 'enabled_post_types',
+ __('Enabled Post Types', 'taler-turnstile'),
+ array($this, 'enabled_post_types_callback'),
+ 'taler_turnstile_settings',
+ 'taler_turnstile_main_section'
+ );
+
+ add_settings_field(
+ 'payment_backend_url',
+ __('Payment Backend URL', 'taler-turnstile'),
+ array($this, 'payment_backend_url_callback'),
+ 'taler_turnstile_settings',
+ 'taler_turnstile_backend_section'
+ );
+
+ add_settings_field(
+ 'access_token',
+ __('Access Token', 'taler-turnstile'),
+ array($this, 'access_token_callback'),
+ 'taler_turnstile_settings',
+ 'taler_turnstile_backend_section'
+ );
+
+ add_settings_field(
+ 'grant_access_on_error',
+ __('Disable Turnstile on Error', 'taler-turnstile'),
+ array($this, 'grant_access_on_error_callback'),
+ 'taler_turnstile_settings',
+ 'taler_turnstile_backend_section'
+ );
+ }
+
+ public function main_section_callback() {
+ echo '<p>' . esc_html__('Configure which post types should have the price field and be subject to paywall transformation.', 'taler-turnstile') . '</p>';
+ }
+
+ public function backend_section_callback() {
+ echo '<p>' . esc_html__('Configure your GNU Taler merchant backend connection.', 'taler-turnstile') . '</p>';
+ }
+
+ public function enabled_post_types_callback() {
+ $enabled_types = get_option('taler_turnstile_enabled_post_types', array('post'));
+ $post_types = get_post_types(array('public' => true), 'objects');
+
+ foreach ($post_types as $post_type) {
+ $checked = in_array($post_type->name, $enabled_types) ? 'checked' : '';
+ echo '<label style="display: block; margin-bottom: 5px;">';
+ echo '<input type="checkbox" name="taler_turnstile_enabled_post_types[]" value="' . esc_attr($post_type->name) . '" ' . $checked . '> ';
+ echo esc_html($post_type->label);
+ echo '</label>';
+ }
+ echo '<p class="description">' . esc_html__('Select which post types should have the price field and be subject to paywall transformation.', 'taler-turnstile') . '</p>';
+ }
+
+ public function payment_backend_url_callback() {
+ $value = get_option('taler_turnstile_payment_backend_url', '');
+ echo '<input type="url" name="taler_turnstile_payment_backend_url" value="' . esc_attr($value) . '" class="regular-text" />';
+ echo '<p class="description">' . esc_html__('HTTP URL for the payment backend service. Must end with a "/".', 'taler-turnstile') . '</p>';
+ }
+
+ public function access_token_callback() {
+ $value = get_option('taler_turnstile_access_token', '');
+ echo '<input type="text" name="taler_turnstile_access_token" value="' . esc_attr($value) . '" class="regular-text" />';
+ echo '<p class="description">' . esc_html__('Access token for authenticating with the payment backend. Must begin with "secret-token:".', 'taler-turnstile') . '</p>';
+ }
+
+ public function grant_access_on_error_callback() {
+ $value = get_option('taler_turnstile_grant_access_on_error', false);
+ echo '<label>';
+ echo '<input type="checkbox" name="taler_turnstile_grant_access_on_error" value="1" ' . checked($value, true, false) . ' />';
+ echo ' ' . esc_html__('Allows users gratis access when Turnstile is unable to communicate with the GNU Taler merchant backend. Use this setting to avoid exposing users to configuration errors.', 'taler-turnstile');
+ echo '</label>';
+ }
+
+ public function sanitize_post_types($input) {
+ if (!is_array($input)) {
+ return array();
+ }
+
+ $valid_post_types = get_post_types(array('public' => true));
+ $new_enabled_types = array_values(array_intersect($input, array_keys($valid_post_types)));
+
+ // Get old enabled types to determine what changed
+ $old_enabled_types = get_option('taler_turnstile_enabled_post_types', array());
+
+ // Find types to add and remove
+ $types_to_add = array_diff($new_enabled_types, $old_enabled_types);
+ $types_to_remove = array_diff($old_enabled_types, $new_enabled_types);
+
+ // Add fields to newly enabled post types
+ if (!empty($types_to_add)) {
+ Taler_Field_Manager::add_fields_to_post_types($types_to_add);
+
+ // Add success message
+ $type_labels = array();
+ foreach ($types_to_add as $type) {
+ $post_type_obj = get_post_type_object($type);
+ if ($post_type_obj) {
+ $type_labels[] = $post_type_obj->label;
+ }
+ }
+
+ if (!empty($type_labels)) {
+ add_settings_error(
+ 'taler_turnstile_enabled_post_types',
+ 'fields_added',
+ sprintf(
+ __('Price category fields added to: %s', 'taler-turnstile'),
+ implode(', ', $type_labels)
+ ),
+ 'success'
+ );
+ }
+ }
+
+ // Remove fields from disabled post types
+ if (!empty($types_to_remove)) {
+ Taler_Field_Manager::remove_fields_from_post_types($types_to_remove);
+
+ // Add success message
+ $type_labels = array();
+ foreach ($types_to_remove as $type) {
+ $post_type_obj = get_post_type_object($type);
+ if ($post_type_obj) {
+ $type_labels[] = $post_type_obj->label;
+ }
+ }
+
+ if (!empty($type_labels)) {
+ add_settings_error(
+ 'taler_turnstile_enabled_post_types',
+ 'fields_removed',
+ sprintf(
+ __('Price category fields removed from: %s', 'taler-turnstile'),
+ implode(', ', $type_labels)
+ ),
+ 'success'
+ );
+ }
+ }
+
+ // Cleanup field storage if no types are enabled
+ if (empty($new_enabled_types)) {
+ Taler_Field_Manager::cleanup_field_storage();
+ }
+
+ return $new_enabled_types;
+ }
+
+ public function sanitize_backend_url($input) {
+ $input = trim($input);
+
+ if (empty($input)) {
+ return '';
+ }
+
+ if (!str_ends_with($input, '/')) {
+ add_settings_error(
+ 'taler_turnstile_payment_backend_url',
+ 'invalid_url',
+ __('Payment backend URL must end with a "/".', 'taler-turnstile'),
+ 'error'
+ );
+ return get_option('taler_turnstile_payment_backend_url');
+ }
+
+ if (!Taler_Merchant_API::check_config($input)) {
+ add_settings_error(
+ 'taler_turnstile_payment_backend_url',
+ 'invalid_url',
+ __('Invalid payment backend URL', 'taler-turnstile'),
+ 'error'
+ );
+ return get_option('taler_turnstile_payment_backend_url');
+ }
+
+ // Check backend access
+ $token = get_option('taler_turnstile_access_token');
+ $http_status = Taler_Merchant_API::check_access($input, $token);
+
+ $this->validate_http_status($http_status);
+
+ return esc_url_raw($input);
+ }
+
+ public function sanitize_access_token($input) {
+ $input = trim($input);
+
+ if (empty($input)) {
+ return '';
+ }
+
+ if (!str_starts_with($input, 'secret-token:')) {
+ add_settings_error(
+ 'taler_turnstile_access_token',
+ 'invalid_token',
+ __('Access token must begin with "secret-token:".', 'taler-turnstile'),
+ 'error'
+ );
+ return get_option('taler_turnstile_access_token');
+ }
+
+ return sanitize_text_field($input);
+ }
+
+ private function validate_http_status($http_status) {
+ $messages = array(
+ 502 => __('Bad gateway (502) trying to access the merchant backend', 'taler-turnstile'),
+ 500 => __('Internal server error (500) of the merchant backend reported', 'taler-turnstile'),
+ 404 => __('The specified instance is unknown to the merchant backend', 'taler-turnstile'),
+ 403 => __('Access token not accepted by the merchant backend', 'taler-turnstile'),
+ 401 => __('Access token not accepted by the merchant backend', 'taler-turnstile'),
+ 0 => __('HTTP request failed', 'taler-turnstile')
+ );
+
+ if (isset($messages[$http_status])) {
+ add_settings_error(
+ 'taler_turnstile_settings',
+ 'backend_error',
+ $messages[$http_status],
+ 'error'
+ );
+ } elseif ($http_status !== 200 && $http_status !== 204) {
+ add_settings_error(
+ 'taler_turnstile_settings',
+ 'backend_error',
+ sprintf(__('Unexpected response (%d) from merchant backend', 'taler-turnstile'), $http_status),
+ 'error'
+ );
+ }
+
+ // Warning for incomplete configuration
+ $backend_url = get_option('taler_turnstile_payment_backend_url');
+ $access_token = get_option('taler_turnstile_access_token');
+ $grant_access = get_option('taler_turnstile_grant_access_on_error');
+
+ if (!$grant_access && (empty($backend_url) || empty($access_token))) {
+ add_settings_error(
+ 'taler_turnstile_settings',
+ 'incomplete_config',
+ __('Warning: Merchant backend is not configured correctly. To keep the site working, you probably should set the "Disable Turnstile when payment backend is unavailable" option!', 'taler-turnstile'),
+ 'warning'
+ );
+ }
+ }
+
+ public static function render_settings_page() {
+ if (!current_user_can('manage_options')) {
+ return;
+ }
+ ?>
+ <div class="wrap">
+ <h1><?php echo esc_html(get_admin_page_title()); ?></h1>
+
+ <?php
+ // Check if backend is configured and show link to subscription prices
+ $backend_url = get_option('taler_turnstile_payment_backend_url');
+ $access_token = get_option('taler_turnstile_access_token');
+
+ if (!empty($backend_url) && !empty($access_token)) {
+ ?>
+ <div class="notice notice-info">
+ <p>
+ <?php
+ printf(
+ esc_html__('Backend configured successfully! You can now %sconfigure subscription prices%s.', 'taler-turnstile'),
+ '<a href="' . esc_url(admin_url('admin.php?page=taler-subscription-prices')) . '">',
+ '</a>'
+ );
+ ?>
+ </p>
+ </div>
+ <?php
+ }
+ ?>
+
+ <form method="post" action="options.php">
+ <?php
+ settings_fields('taler_turnstile_settings');
+ do_settings_sections('taler_turnstile_settings');
+ submit_button(__('Save Settings', 'taler-turnstile'));
+ ?>
+ </form>
+ </div>
+ <?php
+ }
+}
+\ No newline at end of file
diff --git a/admin/class-price-category-admin.php b/admin/class-price-category-admin.php
@@ -0,0 +1,256 @@
+<?php
+/**
+ * Price Category Admin
+ *
+ * Handles the price category management interface.
+ */
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class Taler_Turnstile_Price_Category_Admin {
+
+ public function __construct() {
+ add_action('admin_post_taler_save_price_category', array($this, 'save_price_category'));
+ add_action('admin_post_taler_delete_price_category', array($this, 'delete_price_category'));
+ }
+
+ public static function render_list_page() {
+ if (!current_user_can('manage_options')) {
+ return;
+ }
+
+ $categories = Taler_Price_Category::get_all();
+ ?>
+ <div class="wrap">
+ <h1>
+ <?php esc_html_e('Price Categories', 'taler-turnstile'); ?>
+ <a href="<?php echo esc_url(admin_url('admin.php?page=taler-price-category-add')); ?>" class="page-title-action">
+ <?php esc_html_e('Add New', 'taler-turnstile'); ?>
+ </a>
+ </h1>
+
+ <?php if (empty($categories)): ?>
+ <p><?php esc_html_e('No price categories found. Create your first price category to get started.', 'taler-turnstile'); ?></p>
+ <?php else: ?>
+ <table class="wp-list-table widefat fixed striped">
+ <thead>
+ <tr>
+ <th><?php esc_html_e('Name', 'taler-turnstile'); ?></th>
+ <th><?php esc_html_e('Machine Name', 'taler-turnstile'); ?></th>
+ <th><?php esc_html_e('Description', 'taler-turnstile'); ?></th>
+ <th><?php esc_html_e('Actions', 'taler-turnstile'); ?></th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($categories as $id => $category): ?>
+ <tr>
+ <td><strong><?php echo esc_html($category['label']); ?></strong></td>
+ <td><?php echo esc_html($id); ?></td>
+ <td><?php echo esc_html($category['description'] ?? ''); ?></td>
+ <td>
+ <a href="<?php echo esc_url(admin_url('admin.php?page=taler-price-category-add&edit=' . urlencode($id))); ?>">
+ <?php esc_html_e('Edit', 'taler-turnstile'); ?>
+ </a>
+ |
+ <a href="<?php echo esc_url(wp_nonce_url(admin_url('admin-post.php?action=taler_delete_price_category&id=' . urlencode($id)), 'delete_price_category_' . $id)); ?>"
+ onclick="return confirm('<?php esc_attr_e('Are you sure you want to delete this price category?', 'taler-turnstile'); ?>');">
+ <?php esc_html_e('Delete', 'taler-turnstile'); ?>
+ </a>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ <?php endif; ?>
+ </div>
+ <?php
+ }
+
+ public static function render_edit_page() {
+ if (!current_user_can('manage_options')) {
+ return;
+ }
+
+ $edit_id = isset($_GET['edit']) ? sanitize_key($_GET['edit']) : '';
+ $category = $edit_id ? Taler_Price_Category::get($edit_id) : null;
+
+ $is_new = empty($category);
+ $label = $category['label'] ?? '';
+ $description = $category['description'] ?? '';
+ $prices = $category['prices'] ?? array();
+
+ $subscriptions = Taler_Merchant_API::get_subscriptions();
+ $currencies = Taler_Merchant_API::get_currencies();
+
+ if (empty($subscriptions) || empty($currencies)) {
+ echo '<div class="notice notice-warning"><p>';
+ esc_html_e('Unable to load subscriptions or currencies from API. Please check your configuration.', 'taler-turnstile');
+ echo '</p></div>';
+ }
+ ?>
+ <div class="wrap">
+ <h1><?php echo $is_new ? esc_html__('Add Price Category', 'taler-turnstile') : esc_html__('Edit Price Category', 'taler-turnstile'); ?></h1>
+
+ <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
+ <input type="hidden" name="action" value="taler_save_price_category">
+ <?php wp_nonce_field('taler_save_price_category'); ?>
+
+ <?php if (!$is_new): ?>
+ <input type="hidden" name="id" value="<?php echo esc_attr($edit_id); ?>">
+ <?php endif; ?>
+
+ <table class="form-table">
+ <tr>
+ <th scope="row">
+ <label for="label"><?php esc_html_e('Name', 'taler-turnstile'); ?> <span class="required">*</span></label>
+ </th>
+ <td>
+ <input type="text" name="label" id="label" value="<?php echo esc_attr($label); ?>" class="regular-text" required>
+ <p class="description"><?php esc_html_e('The name of the price category.', 'taler-turnstile'); ?></p>
+ </td>
+ </tr>
+
+ <?php if ($is_new): ?>
+ <tr>
+ <th scope="row">
+ <label for="id"><?php esc_html_e('Machine Name', 'taler-turnstile'); ?> <span class="required">*</span></label>
+ </th>
+ <td>
+ <input type="text" name="id" id="id" value="<?php echo esc_attr($edit_id); ?>" class="regular-text" pattern="[a-z0-9_]+" required>
+ <p class="description"><?php esc_html_e('Lowercase letters, numbers, and underscores only.', 'taler-turnstile'); ?></p>
+ </td>
+ </tr>
+ <?php endif; ?>
+
+ <tr>
+ <th scope="row">
+ <label for="description"><?php esc_html_e('Description', 'taler-turnstile'); ?></label>
+ </th>
+ <td>
+ <textarea name="description" id="description" rows="3" class="large-text"><?php echo esc_textarea($description); ?></textarea>
+ <p class="description"><?php esc_html_e('A description of this price category.', 'taler-turnstile'); ?></p>
+ </td>
+ </tr>
+ </table>
+
+ <h2><?php esc_html_e('Prices', 'taler-turnstile'); ?></h2>
+
+ <?php foreach ($subscriptions as $subscription_id => $subscription): ?>
+ <div class="postbox">
+ <div class="postbox-header">
+ <h2 class="hndle"><?php echo esc_html($subscription['label']); ?></h2>
+ </div>
+ <div class="inside">
+ <table class="form-table">
+ <?php foreach ($currencies as $currency): ?>
+ <?php
+ $currency_code = $currency['code'];
+ $field_name = 'prices[' . esc_attr($subscription_id) . '][' . esc_attr($currency_code) . ']';
+ $field_value = $prices[$subscription_id][$currency_code] ?? '';
+ ?>
+ <tr>
+ <th scope="row">
+ <label for="price_<?php echo esc_attr($subscription_id . '_' . $currency_code); ?>">
+ <?php echo esc_html($currency['label']); ?>
+ </label>
+ </th>
+ <td>
+ <input type="number"
+ name="<?php echo esc_attr($field_name); ?>"
+ id="price_<?php echo esc_attr($subscription_id . '_' . $currency_code); ?>"
+ value="<?php echo esc_attr($field_value); ?>"
+ min="0"
+ step="<?php echo esc_attr($currency['step']); ?>"
+ class="small-text">
+ <p class="description"><?php esc_html_e('Leave empty for no price.', 'taler-turnstile'); ?></p>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ </table>
+ </div>
+ </div>
+ <?php endforeach; ?>
+
+ <p class="submit">
+ <input type="submit" name="submit" id="submit" class="button button-primary" value="<?php echo $is_new ? esc_attr__('Create Price Category', 'taler-turnstile') : esc_attr__('Update Price Category', 'taler-turnstile'); ?>">
+ <a href="<?php echo esc_url(admin_url('admin.php?page=taler-price-categories')); ?>" class="button">
+ <?php esc_html_e('Cancel', 'taler-turnstile'); ?>
+ </a>
+ </p>
+ </form>
+ </div>
+ <?php
+ }
+
+ public function save_price_category() {
+ if (!current_user_can('manage_options')) {
+ wp_die(__('You do not have sufficient permissions to access this page.', 'taler-turnstile'));
+ }
+
+ check_admin_referer('taler_save_price_category');
+
+ $id = isset($_POST['id']) ? sanitize_key($_POST['id']) : '';
+ $label = isset($_POST['label']) ? sanitize_text_field($_POST['label']) : '';
+ $description = isset($_POST['description']) ? sanitize_textarea_field($_POST['description']) : '';
+ $prices = isset($_POST['prices']) ? $_POST['prices'] : array();
+
+ // Generate ID from label if creating new
+ if (empty($id)) {
+ $id = sanitize_key(str_replace(' ', '_', strtolower($label)));
+ }
+
+ // Validate and filter prices
+ $filtered_prices = array();
+ if (is_array($prices)) {
+ foreach ($prices as $subscription_id => $currencies) {
+ if (is_array($currencies)) {
+ foreach ($currencies as $currency_code => $price) {
+ if ($price !== '' && is_numeric($price) && $price >= 0) {
+ $filtered_prices[$subscription_id][$currency_code] = floatval($price);
+ }
+ }
+ }
+ }
+ }
+
+ $category_data = array(
+ 'label' => $label,
+ 'description' => $description,
+ 'prices' => $filtered_prices
+ );
+
+ Taler_Price_Category::save($id, $category_data);
+
+ $message = isset($_POST['id']) ? __('Price category updated.', 'taler-turnstile') : __('Price category created.', 'taler-turnstile');
+
+ wp_redirect(add_query_arg(
+ array('page' => 'taler-price-categories', 'message' => urlencode($message)),
+ admin_url('admin.php')
+ ));
+ exit;
+ }
+
+ public function delete_price_category() {
+ if (!current_user_can('manage_options')) {
+ wp_die(__('You do not have sufficient permissions to access this page.', 'taler-turnstile'));
+ }
+
+ $id = isset($_GET['id']) ? sanitize_key($_GET['id']) : '';
+
+ check_admin_referer('delete_price_category_' . $id);
+
+ if (Taler_Price_Category::delete($id)) {
+ $message = __('Price category deleted.', 'taler-turnstile');
+ } else {
+ $message = __('Failed to delete price category.', 'taler-turnstile');
+ }
+
+ wp_redirect(add_query_arg(
+ array('page' => 'taler-price-categories', 'message' => urlencode($message)),
+ admin_url('admin.php')
+ ));
+ exit;
+ }
+}
+\ No newline at end of file
diff --git a/admin/class-subscription-prices-admin.php b/admin/class-subscription-prices-admin.php
@@ -0,0 +1,247 @@
+<?php
+/**
+ * Subscription Prices Admin
+ *
+ * Handles the subscription prices settings page.
+ */
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class Taler_Subscription_Prices_Admin {
+
+ public function __construct() {
+ add_action('admin_init', array($this, 'register_settings'));
+ add_action('admin_post_taler_save_subscription_prices', array($this, 'save_subscription_prices'));
+ }
+
+ public function register_settings() {
+ register_setting('taler_subscription_prices', 'taler_turnstile_subscription_prices', array(
+ 'type' => 'array',
+ 'default' => array(),
+ 'sanitize_callback' => array($this, 'sanitize_subscription_prices')
+ ));
+ }
+
+ public function sanitize_subscription_prices($input) {
+ if (!is_array($input)) {
+ return array();
+ }
+
+ $sanitized = array();
+
+ foreach ($input as $subscription_id => $currencies) {
+ if (!is_array($currencies)) {
+ continue;
+ }
+
+ foreach ($currencies as $currency_code => $price) {
+ if ($price === '' || $price === null) {
+ continue;
+ }
+
+ if (is_numeric($price) && $price >= 0) {
+ $sanitized[$subscription_id][$currency_code] = floatval($price);
+ }
+ }
+ }
+
+ // Clear payment choices cache when subscription prices change
+ wp_cache_flush();
+
+ return $sanitized;
+ }
+
+ public static function render_settings_page() {
+ if (!current_user_can('manage_options')) {
+ return;
+ }
+
+ // Check if backend is configured
+ $backend_url = get_option('taler_turnstile_payment_backend_url');
+ $access_token = get_option('taler_turnstile_access_token');
+
+ if (empty($backend_url) || empty($access_token)) {
+ ?>
+ <div class="wrap">
+ <h1><?php echo esc_html(get_admin_page_title()); ?></h1>
+ <div class="notice notice-error">
+ <p>
+ <?php
+ printf(
+ esc_html__('Turnstile payment backend is not configured. Please %sconfigure the backend%s first.', 'taler-turnstile'),
+ '<a href="' . esc_url(admin_url('options-general.php?page=taler-turnstile-settings')) . '">',
+ '</a>'
+ );
+ ?>
+ </p>
+ </div>
+ </div>
+ <?php
+ return;
+ }
+
+ // Get subscriptions and currencies
+ $subscriptions = Taler_Merchant_API::get_subscriptions();
+ $currencies = Taler_Merchant_API::get_currencies();
+
+ if (empty($currencies)) {
+ ?>
+ <div class="wrap">
+ <h1><?php echo esc_html(get_admin_page_title()); ?></h1>
+ <div class="notice notice-error">
+ <p><?php esc_html_e('Unable to load currencies from the API. Please check your backend configuration.', 'taler-turnstile'); ?></p>
+ </div>
+ </div>
+ <?php
+ return;
+ }
+
+ if (empty($subscriptions) || (count($subscriptions) === 1 && isset($subscriptions['%none%']))) {
+ ?>
+ <div class="wrap">
+ <h1><?php echo esc_html(get_admin_page_title()); ?></h1>
+ <div class="notice notice-warning">
+ <p><?php esc_html_e('No subscriptions configured in Taler merchant backend.', 'taler-turnstile'); ?></p>
+ </div>
+ </div>
+ <?php
+ return;
+ }
+
+ $existing_prices = get_option('taler_turnstile_subscription_prices', array());
+
+ ?>
+ <div class="wrap taler-subscription-prices">
+ <h1><?php echo esc_html(get_admin_page_title()); ?></h1>
+
+ <p><?php esc_html_e('Set the price for buying each subscription type in different currencies. Leave a field empty to prevent users from buying that subscription with that currency.', 'taler-turnstile'); ?></p>
+
+ <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
+ <input type="hidden" name="action" value="taler_save_subscription_prices">
+ <?php wp_nonce_field('taler_save_subscription_prices'); ?>
+
+ <?php foreach ($subscriptions as $subscription_id => $subscription): ?>
+ <?php
+ // Skip the %none% case as you can't buy "no subscription"
+ if ($subscription_id === '%none%') {
+ continue;
+ }
+
+ $subscription_label = $subscription['label'];
+ $subscription_description = isset($subscription['description']) ? $subscription['description'] : '';
+ ?>
+
+ <div class="postbox">
+ <div class="postbox-header">
+ <h2 class="hndle"><?php echo esc_html($subscription_label); ?></h2>
+ </div>
+ <div class="inside">
+ <?php if (!empty($subscription_description)): ?>
+ <p class="description"><?php echo esc_html($subscription_description); ?></p>
+ <?php endif; ?>
+
+ <table class="form-table">
+ <?php foreach ($currencies as $currency): ?>
+ <?php
+ $currency_code = $currency['code'];
+ $currency_label = $currency['label'];
+ $field_name = 'subscription_prices[' . esc_attr($subscription_id) . '][' . esc_attr($currency_code) . ']';
+ $field_value = isset($existing_prices[$subscription_id][$currency_code]) ? $existing_prices[$subscription_id][$currency_code] : '';
+ ?>
+ <tr>
+ <th scope="row">
+ <label for="sub_price_<?php echo esc_attr($subscription_id . '_' . $currency_code); ?>">
+ <?php echo esc_html($currency_label); ?>
+ </label>
+ </th>
+ <td>
+ <input type="number"
+ name="<?php echo esc_attr($field_name); ?>"
+ id="sub_price_<?php echo esc_attr($subscription_id . '_' . $currency_code); ?>"
+ value="<?php echo esc_attr($field_value); ?>"
+ min="0"
+ step="<?php echo esc_attr($currency['step']); ?>"
+ class="small-text">
+ <p class="description">
+ <?php
+ printf(
+ esc_html__('Leave empty to prevent buying this subscription with %s.', 'taler-turnstile'),
+ esc_html($currency_code)
+ );
+ ?>
+ </p>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ </table>
+ </div>
+ </div>
+
+ <?php endforeach; ?>
+
+ <p class="submit">
+ <input type="submit" name="submit" id="submit" class="button button-primary" value="<?php esc_attr_e('Save Subscription Prices', 'taler-turnstile'); ?>">
+ </p>
+ </form>
+ </div>
+ <?php
+ }
+
+ public function save_subscription_prices() {
+ if (!current_user_can('manage_options')) {
+ wp_die(__('You do not have sufficient permissions to access this page.', 'taler-turnstile'));
+ }
+
+ check_admin_referer('taler_save_subscription_prices');
+
+ $subscription_prices = isset($_POST['subscription_prices']) ? $_POST['subscription_prices'] : array();
+
+ // Validate and sanitize
+ $sanitized_prices = $this->sanitize_subscription_prices($subscription_prices);
+
+ // Check for validation errors
+ $has_errors = false;
+
+ if (is_array($subscription_prices)) {
+ foreach ($subscription_prices as $subscription_id => $currencies) {
+ if (is_array($currencies)) {
+ foreach ($currencies as $currency_code => $price) {
+ if ($price !== '' && $price !== null) {
+ if (!is_numeric($price) || $price < 0) {
+ add_settings_error(
+ 'taler_subscription_prices',
+ 'invalid_price',
+ __('Subscription prices cannot be negative.', 'taler-turnstile'),
+ 'error'
+ );
+ $has_errors = true;
+ break 2;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (!$has_errors) {
+ update_option('taler_turnstile_subscription_prices', $sanitized_prices);
+
+ add_settings_error(
+ 'taler_subscription_prices',
+ 'prices_saved',
+ __('Subscription prices saved successfully.', 'taler-turnstile'),
+ 'success'
+ );
+ }
+
+ set_transient('settings_errors', get_settings_errors(), 30);
+
+ wp_redirect(add_query_arg(
+ array('page' => 'taler-subscription-prices', 'settings-updated' => 'true'),
+ admin_url('admin.php')
+ ));
+ exit;
+ }
+}
+\ No newline at end of file
diff --git a/assets/css/admin.css b/assets/css/admin.css
@@ -0,0 +1,64 @@
+/**
+ * Admin Styles for Taler Turnstile
+ */
+
+.taler-turnstile-settings .form-table th {
+ width: 200px;
+}
+
+.taler-turnstile-settings .required {
+ color: #d63638;
+}
+
+.taler-turnstile-settings .postbox {
+ margin-bottom: 20px;
+}
+
+.taler-turnstile-settings .postbox .inside {
+ padding: 0 12px 12px;
+}
+
+.taler-turnstile-settings .form-table {
+ margin-top: 0;
+}
+
+.taler-turnstile-settings .form-table td {
+ padding: 10px;
+}
+
+/* Price category list table */
+.taler-price-categories-list table.wp-list-table {
+ margin-top: 20px;
+}
+
+/* Notice styling */
+.notice.notice-warning p {
+ font-weight: 500;
+}
+
+/* Form field styling */
+.taler-turnstile-settings input[type="url"],
+.taler-turnstile-settings input[type="text"],
+.taler-turnstile-settings input[type="number"] {
+ width: 100%;
+ max-width: 25em;
+}
+
+.taler-turnstile-settings textarea {
+ width: 100%;
+ max-width: 50em;
+}
+
+/* Checkbox list styling */
+.taler-turnstile-settings label {
+ display: inline-block;
+}
+
+/* Subscription price sections */
+.taler-subscription-prices .postbox {
+ max-width: 800px;
+}
+
+.taler-subscription-prices .form-table td input[type="number"] {
+ max-width: 150px;
+}
diff --git a/assets/css/frontend.css b/assets/css/frontend.css
@@ -0,0 +1,196 @@
+/**
+ * Frontend Styles for Taler Turnstile Paywall
+ */
+
+/* Paywall Container */
+.taler-turnstile-paywall {
+ margin: 2em 0;
+ padding: 2em;
+ background: #f9f9f9;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+}
+
+.taler-turnstile-excerpt {
+ margin-bottom: 2em;
+ font-size: 1.1em;
+ line-height: 1.6;
+}
+
+/* Payment Container */
+.taler-payment-container {
+ background: #fff;
+ padding: 2em;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+ text-align: center;
+}
+
+.taler-payment-container h3 {
+ margin-top: 0;
+ color: #333;
+ font-size: 1.8em;
+}
+
+.taler-payment-container > p {
+ color: #666;
+ margin-bottom: 2em;
+}
+
+/* Payment Methods Layout */
+.taler-payment-methods {
+ display: flex;
+ gap: 3em;
+ justify-content: center;
+ align-items: flex-start;
+ margin: 2em 0;
+ flex-wrap: wrap;
+}
+
+.taler-qr-section,
+.taler-button-section {
+ flex: 1;
+ min-width: 250px;
+ max-width: 350px;
+}
+
+.taler-qr-section h4,
+.taler-button-section h4 {
+ margin-top: 0;
+ margin-bottom: 1em;
+ color: #555;
+ font-size: 1.2em;
+}
+
+/* QR Code Container */
+.taler-turnstile-qr-code-container {
+ display: inline-block;
+ padding: 1em;
+ background: #fff;
+ border: 2px solid #e0e0e0;
+ border-radius: 8px;
+ margin-bottom: 1em;
+}
+
+.taler-turnstile-qr-code-container img {
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+
+/* Payment Button */
+.taler-pay-button {
+ display: inline-block;
+ padding: 1em 2em;
+ font-size: 1.1em;
+ font-weight: 600;
+ text-decoration: none;
+ background: #0073aa;
+ color: #fff !important;
+ border-radius: 5px;
+ transition: background 0.3s ease;
+ border: none;
+ cursor: pointer;
+}
+
+.taler-pay-button:hover {
+ background: #005177;
+ color: #fff !important;
+}
+
+.taler-pay-button:focus {
+ outline: 2px solid #0073aa;
+ outline-offset: 2px;
+}
+
+/* Payment Info */
+.taler-payment-info {
+ margin-top: 2em;
+ padding-top: 1em;
+ border-top: 1px solid #e0e0e0;
+}
+
+.taler-order-id {
+ color: #999;
+ font-size: 0.9em;
+ margin: 0;
+}
+
+/* Description Text */
+.taler-qr-section .description,
+.taler-button-section .description {
+ font-size: 0.9em;
+ color: #666;
+ font-style: italic;
+ margin-top: 0.5em;
+}
+
+/* Error Message */
+.taler-turnstile-error {
+ background: #fff3cd;
+ border: 1px solid #ffc107;
+ border-radius: 5px;
+ padding: 1.5em;
+ margin: 2em 0;
+}
+
+.taler-turnstile-error p {
+ margin: 0.5em 0;
+ color: #856404;
+}
+
+.taler-turnstile-error strong {
+ color: #856404;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ .taler-payment-methods {
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .taler-qr-section,
+ .taler-button-section {
+ max-width: 100%;
+ }
+
+ .taler-turnstile-paywall {
+ padding: 1.5em;
+ }
+
+ .taler-payment-container {
+ padding: 1.5em;
+ }
+}
+
+/* Loading State (optional enhancement) */
+.taler-payment-container.checking-payment {
+ position: relative;
+}
+
+.taler-payment-container.checking-payment::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(255, 255, 255, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Accessibility */
+.taler-pay-button:focus-visible {
+ outline: 3px solid #0073aa;
+ outline-offset: 3px;
+}
+
+/* Print Styles */
+@media print {
+ .taler-payment-container {
+ display: none;
+ }
+}
diff --git a/assets/js/LICENSE b/assets/js/LICENSE
@@ -0,0 +1,14 @@
+The MIT License (MIT)
+---------------------
+Copyright (c) 2012 davidshimjs
+
+Permission is hereby granted, free of charge,
+to any person obtaining a copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction,
+including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+\ No newline at end of file
diff --git a/assets/js/QRCODE-README.m4 b/assets/js/QRCODE-README.m4
@@ -0,0 +1,30 @@
+# QRCode.js Library
+
+This plugin requires the QRCode.js library for generating QR codes.
+
+## Installation
+
+Download `qrcode.min.js` from:
+https://github.com/davidshimjs/qrcodejs
+
+Place the minified file as:
+`assets/js/qrcode.min.js`
+
+## Alternative CDN Usage
+
+If you prefer to use a CDN instead, modify the enqueue script in `taler-turnstile.php`:
+
+```php
+// Replace the qrcode enqueue with:
+wp_enqueue_script(
+ 'qrcode',
+ 'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js',
+ array(),
+ '1.0.0',
+ true
+);
+```
+
+## License
+
+QRCode.js is licensed under the MIT License.
diff --git a/assets/js/README b/assets/js/README
@@ -0,0 +1 @@
+qrcode.min.js is from https://github.com/davidshimjs/qrcodejs, see LICENSE.
+\ No newline at end of file
diff --git a/assets/js/admin.js b/assets/js/admin.js
@@ -0,0 +1,60 @@
+/**
+ * Admin JavaScript for Taler Turnstile
+ */
+
+(function($) {
+ 'use strict';
+
+ $(document).ready(function() {
+
+ // Auto-generate machine name from label
+ $('#label').on('input', function() {
+ if ($('#id').length && $('#id').val() === '') {
+ var machineName = $(this).val()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '');
+ $('#id').val(machineName);
+ }
+ });
+
+ // Validate payment backend URL format
+ $('input[name="taler_turnstile_payment_backend_url"]').on('blur', function() {
+ var url = $(this).val().trim();
+ if (url !== '' && !url.endsWith('/')) {
+ alert('Payment backend URL must end with a "/".');
+ }
+ });
+
+ // Validate access token format
+ $('input[name="taler_turnstile_access_token"]').on('blur', function() {
+ var token = $(this).val().trim();
+ if (token !== '' && !token.startsWith('secret-token:')) {
+ alert('Access token must begin with "secret-token:".');
+ }
+ });
+
+ // Confirm deletion
+ $('.delete-price-category').on('click', function(e) {
+ if (!confirm('Are you sure you want to delete this price category?')) {
+ e.preventDefault();
+ return false;
+ }
+ });
+
+ // Toggle subscription details
+ $('.postbox .hndle').on('click', function() {
+ $(this).closest('.postbox').toggleClass('closed');
+ });
+
+ // Add visual feedback for negative prices
+ $('input[type="number"][min="0"]').on('input', function() {
+ if (parseFloat($(this).val()) < 0) {
+ $(this).css('border-color', '#d63638');
+ } else {
+ $(this).css('border-color', '');
+ }
+ });
+ });
+
+})(jQuery);
diff --git a/assets/js/payment-button.js b/assets/js/payment-button.js
@@ -0,0 +1,168 @@
+/**
+ * JavaScript for GNU Taler Turnstile payment button functionality.
+ */
+
+(function($) {
+ 'use strict';
+
+ /**
+ * Convert HTTP(S) payment URL to Taler URI format
+ *
+ * @param {string} paymentUrl - The HTTP(S) payment URL
+ * @param {string} sessionId - The hashed session ID
+ * @returns {string} The Taler 'pay' URI including session ID
+ */
+ function convertToTalerUri(paymentUrl, sessionId) {
+ try {
+ var url = new URL(paymentUrl);
+ var protocol = url.protocol; // 'https:' or 'http:'
+ var host = url.host; // includes port if specified
+ var pathname = url.pathname; // e.g., '/something/orders/12345'
+
+ // Extract the path components, removing '/orders/' part
+ // Expected input: [/instance/$ID]/orders/$ORDER_ID
+ // Expected output: [/instance/$ID]/$ORDER_ID
+ var pathParts = pathname.split('/').filter(function(part) {
+ return part.length > 0;
+ });
+
+ // Find 'orders' in the path and reconstruct without it
+ var ordersIndex = pathParts.indexOf('orders');
+ var talerPath = '';
+
+ if (ordersIndex !== -1 && ordersIndex < pathParts.length - 1) {
+ // Get parts before 'orders' and after 'orders'
+ var beforeOrders = pathParts.slice(0, ordersIndex);
+ var afterOrders = pathParts.slice(ordersIndex + 1);
+ talerPath = beforeOrders.concat(afterOrders).join('/');
+ } else {
+ console.error('Error converting to Taler URI: "/orders/" not found');
+ return paymentUrl;
+ }
+
+ if (protocol === 'https:') {
+ return 'taler://pay/' + host + '/' + talerPath +
+ '/' + encodeURIComponent(sessionId);
+ } else if (protocol === 'http:') {
+ return 'taler+http://pay/' + host + '/' + talerPath +
+ '/' + encodeURIComponent(sessionId);
+ }
+
+ console.error('Error converting to Taler URI: unsupported protocol');
+ return paymentUrl;
+ } catch (e) {
+ console.error('Error converting to Taler URI:', e);
+ return paymentUrl;
+ }
+ }
+
+ /**
+ * Long-poll the payment URL to check if payment was completed
+ *
+ * @param {string} paymentUrl - The payment URL to poll
+ * @param {string} sessionId - The session ID
+ */
+ function pollPaymentStatus(paymentUrl, sessionId) {
+ var separator = paymentUrl.indexOf('?') !== -1 ? '&' : '?';
+ var timeoutMs = 30000;
+ var pollUrl = paymentUrl + separator + 'timeout_ms=' + timeoutMs +
+ '&session_id=' + encodeURIComponent(sessionId);
+ var startTime = Date.now(); // in milliseconds since Epoch
+
+ $.ajax({
+ url: pollUrl,
+ method: 'GET',
+ timeout: timeoutMs + 5000, // Slightly longer than server timeout
+ headers: {
+ 'Accept': 'application/json'
+ },
+ success: function(data, textStatus, xhr) {
+ // Check if we got 20x (payment completed)
+ if (xhr.status === 200 || xhr.status === 202) {
+ console.log('Payment completed! Reloading page...');
+ window.location.reload();
+ } else if (xhr.status === 402) {
+ console.log('Payment still pending, continuing to poll...');
+ var endTime = Date.now();
+ // Prevent looping faster than the long-poll timeout
+ var delay = (startTime + timeoutMs > endTime)
+ ? (startTime + timeoutMs - endTime)
+ : 0;
+ setTimeout(function() {
+ pollPaymentStatus(paymentUrl, sessionId);
+ }, delay);
+ }
+ },
+ error: function(xhr, textStatus, errorThrown) {
+ // Check if this is a 402 Payment Required response
+ if (xhr.status === 402) {
+ console.log('Payment still required (402), continuing to poll...');
+ pollPaymentStatus(paymentUrl, sessionId);
+ } else if (textStatus === 'timeout') {
+ console.log('Poll timeout, retrying...');
+ var endTime = Date.now();
+ // Prevent looping faster than the long-poll timeout
+ var delay = (startTime + timeoutMs > endTime)
+ ? (startTime + timeoutMs - endTime)
+ : 0;
+ setTimeout(function() {
+ pollPaymentStatus(paymentUrl, sessionId);
+ }, delay);
+ } else {
+ // Other errors - wait a bit before retrying
+ console.log('Poll error: ' + textStatus + ', retrying in 5 seconds...');
+ setTimeout(function() {
+ pollPaymentStatus(paymentUrl, sessionId);
+ }, 5000);
+ }
+ }
+ });
+ }
+
+ /**
+ * Initialize payment button functionality
+ */
+ $(document).ready(function() {
+ // Generate QR codes for all payment containers
+ $('.taler-turnstile-qr-code-container').each(function() {
+ var $container = $(this);
+ var paymentUrl = $container.data('payment-url');
+ var sessionId = $container.data('session-id');
+
+ if (paymentUrl && typeof QRCode !== 'undefined') {
+ $container.empty();
+ var talerUri = convertToTalerUri(paymentUrl, sessionId);
+
+ new QRCode($container[0], {
+ text: talerUri,
+ width: 200,
+ height: 200,
+ colorDark: '#000000',
+ colorLight: '#ffffff',
+ correctLevel: QRCode.CorrectLevel.M
+ });
+
+ // Start polling for payment status
+ console.log('Starting payment status polling for: ' + paymentUrl);
+ pollPaymentStatus(paymentUrl, sessionId);
+ } else if (!window.QRCode) {
+ console.error('QRCode library not loaded');
+ }
+ });
+
+ // Update Taler payment button href to use Taler URI
+ $('.taler-pay-button').each(function() {
+ var $button = $(this);
+ var paymentUrl = $button.attr('href');
+ var $container = $button.closest('.taler-payment-container');
+ var $qrContainer = $container.find('.taler-turnstile-qr-code-container');
+ var sessionId = $qrContainer.data('session-id');
+
+ if (paymentUrl && sessionId) {
+ var talerUri = convertToTalerUri(paymentUrl, sessionId);
+ $button.attr('href', talerUri);
+ }
+ });
+ });
+
+})(jQuery);
diff --git a/assets/js/qrcode.min.js b/assets/js/qrcode.min.js
@@ -0,0 +1 @@
+var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g<a.length&&(j=1==(1&a[g]>>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<<h;for(var h=8;256>h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}();
+\ No newline at end of file
diff --git a/includes/class-content-filter.php b/includes/class-content-filter.php
@@ -0,0 +1,350 @@
+<?php
+/**
+ * Content Filter
+ *
+ * Handles content filtering and paywall logic for protected posts.
+ */
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class Taler_Content_Filter {
+
+ /**
+ * Initialize the content filter
+ */
+ public static function init() {
+ add_filter('the_content', array(__CLASS__, 'filter_content'), 10, 1);
+ add_action('wp', array(__CLASS__, 'disable_cache_for_protected_content'));
+ }
+
+ /**
+ * Disable caching for protected content
+ */
+ public static function disable_cache_for_protected_content() {
+ if (!is_singular()) {
+ return;
+ }
+
+ $post_id = get_the_ID();
+ $price_category_id = get_post_meta($post_id, '_taler_price_category', true);
+
+ if (!empty($price_category_id)) {
+ // Disable page caching for protected content
+ if (!defined('DONOTCACHEPAGE')) {
+ define('DONOTCACHEPAGE', true);
+ }
+ nocache_headers();
+ }
+ }
+
+ /**
+ * Filter post content to show paywall if needed
+ *
+ * @param string $content The post content
+ * @return string Modified content or original content
+ */
+ public static function filter_content($content) {
+ // Only apply on singular post views (not archives, search, etc.)
+ if (!is_singular()) {
+ return $content;
+ }
+
+ // Don't apply in admin or feeds
+ if (is_admin() || is_feed()) {
+ return $content;
+ }
+
+ $post = get_post();
+ if (!$post) {
+ return $content;
+ }
+
+ // Check if this post type is enabled for Turnstile
+ $enabled_types = get_option('taler_turnstile_enabled_post_types', array('post'));
+ if (!in_array($post->post_type, $enabled_types)) {
+ return $content;
+ }
+
+ // Check if a price category is set
+ $price_category_id = get_post_meta($post->ID, '_taler_price_category', true);
+ if (empty($price_category_id)) {
+ return $content;
+ }
+
+ $price_category = Taler_Price_Category::get($price_category_id);
+ if (!$price_category) {
+ error_log('Taler Turnstile: Post has invalid price category');
+ return $content;
+ }
+
+ // Check if user has subscription that grants full access
+ $full_subscriptions = Taler_Price_Category::get_full_subscriptions($price_category['prices']);
+ foreach ($full_subscriptions as $subscription_id) {
+ if (self::is_subscriber($subscription_id)) {
+ error_log('Taler Turnstile: Subscriber detected, granting access');
+ return $content;
+ }
+ }
+
+ // Check if this session already has access
+ if (self::has_session_access($post->ID)) {
+ return $content;
+ }
+
+ // Check if there's an existing order and if it's been paid
+ $order_info = self::get_node_order_info($post->ID);
+ if ($order_info) {
+ $order_status = Taler_Merchant_API::check_order_status($order_info['order_id']);
+
+ if ($order_status && $order_status['paid']) {
+ error_log('Taler Turnstile: Order was paid, granting session access');
+ self::grant_session_access($post->ID);
+
+ if (!empty($order_status['subscription_slug'])) {
+ error_log('Taler Turnstile: Subscription was purchased, granting subscription access');
+ self::grant_subscriber_access(
+ $order_status['subscription_slug'],
+ $order_status['subscription_expiration']
+ );
+ }
+
+ return $content;
+ }
+
+ // Check if order expired
+ if ($order_status &&
+ isset($order_status['order_expiration']) &&
+ $order_status['order_expiration'] < time() + 60) {
+ // Order expired or will expire soon, ignore it
+ $order_info = null;
+ }
+
+ if (!$order_status) {
+ $order_info = null;
+ }
+ }
+
+ // Need to create a new order if we don't have a valid one
+ if (!$order_info) {
+ $order_info = Taler_Merchant_API::create_order($post->ID);
+ }
+
+ if (!$order_info) {
+ error_log('Taler Turnstile: Failed to setup order with Taler merchant backend');
+ $grant_access_on_error = get_option('taler_turnstile_grant_access_on_error', false);
+
+ if ($grant_access_on_error) {
+ error_log('Taler Turnstile: Could not setup order, disabling Turnstile');
+ return $content;
+ }
+
+ return self::render_error_message();
+ }
+
+ // Store order info in session
+ self::store_order_node_mapping($post->ID, $order_info);
+
+ // User needs to pay - show teaser + payment button
+ return self::render_paywall($post, $order_info);
+ }
+
+ /**
+ * Render the paywall with excerpt and payment button
+ *
+ * @param WP_Post $post The post object
+ * @param array $order_info Order information
+ * @return string HTML for paywall
+ */
+ private static function render_paywall($post, $order_info) {
+ $excerpt = $post->post_excerpt;
+
+ // If no excerpt, generate one from content
+ if (empty($excerpt)) {
+ $excerpt = wp_trim_words(strip_shortcodes($post->post_content), 55, '...');
+ }
+
+ ob_start();
+ ?>
+ <div class="taler-turnstile-paywall">
+ <div class="taler-turnstile-excerpt">
+ <?php echo wpautop($excerpt); ?>
+ </div>
+
+ <div class="taler-turnstile-payment-wrapper">
+ <?php echo self::render_payment_button($order_info, $post->post_title); ?>
+ </div>
+ </div>
+ <?php
+ return ob_get_clean();
+ }
+
+ /**
+ * Render the payment button with QR code
+ *
+ * @param array $order_info Order information
+ * @param string $title Post title
+ * @return string HTML for payment button
+ */
+ private static function render_payment_button($order_info, $title) {
+ $order_id = $order_info['order_id'];
+ $session_id = $order_info['session_id'];
+ $payment_url = $order_info['payment_url'];
+
+ ob_start();
+ ?>
+ <div class="taler-payment-container">
+ <h3><?php esc_html_e('Payment Required', 'taler-turnstile'); ?></h3>
+ <p><?php esc_html_e('To access the full content, please complete the payment using GNU Taler.', 'taler-turnstile'); ?></p>
+
+ <div class="taler-payment-methods">
+ <div class="taler-qr-section">
+ <h4><?php esc_html_e('Scan QR Code', 'taler-turnstile'); ?></h4>
+ <div class="taler-turnstile-qr-code-container"
+ data-payment-url="<?php echo esc_attr($payment_url); ?>"
+ data-session-id="<?php echo esc_attr($session_id); ?>">
+ </div>
+ <p class="description"><?php esc_html_e('Scan this QR code with your Taler wallet app', 'taler-turnstile'); ?></p>
+ </div>
+
+ <div class="taler-button-section">
+ <h4><?php esc_html_e('Or Click to Pay', 'taler-turnstile'); ?></h4>
+ <a href="<?php echo esc_url($payment_url); ?>"
+ class="button button-primary taler-pay-button">
+ <?php esc_html_e('Pay with GNU Taler', 'taler-turnstile'); ?>
+ </a>
+ </div>
+ </div>
+
+ <div class="taler-payment-info">
+ <p class="taler-order-id">
+ <small><?php printf(esc_html__('Order ID: %s', 'taler-turnstile'), esc_html($order_id)); ?></small>
+ </p>
+ </div>
+ </div>
+ <?php
+ return ob_get_clean();
+ }
+
+ /**
+ * Render error message when payment system is unavailable
+ *
+ * @return string HTML for error message
+ */
+ private static function render_error_message() {
+ ob_start();
+ ?>
+ <div class="taler-turnstile-error">
+ <p><strong><?php esc_html_e('Payment System Temporarily Unavailable', 'taler-turnstile'); ?></strong></p>
+ <p><?php esc_html_e('We are experiencing technical difficulties with our payment system. Please try again later.', 'taler-turnstile'); ?></p>
+ </div>
+ <?php
+ return ob_get_clean();
+ }
+
+ /**
+ * Grant subscription access for this visitor
+ *
+ * @param string $subscription_slug The subscription slug
+ * @param int $expiration Unix timestamp of expiration
+ */
+ private static function grant_subscriber_access($subscription_slug, $expiration) {
+ if (session_status() === PHP_SESSION_NONE) {
+ session_start();
+ }
+
+ if (!isset($_SESSION['taler_turnstile_subscriptions'])) {
+ $_SESSION['taler_turnstile_subscriptions'] = array();
+ }
+
+ $_SESSION['taler_turnstile_subscriptions'][$subscription_slug] = $expiration;
+ }
+
+ /**
+ * Check if visitor has active subscription
+ *
+ * @param string $subscription_slug The subscription slug
+ * @return bool True if subscribed and not expired
+ */
+ private static function is_subscriber($subscription_slug) {
+ if (session_status() === PHP_SESSION_NONE) {
+ session_start();
+ }
+
+ if (!isset($_SESSION['taler_turnstile_subscriptions'][$subscription_slug])) {
+ return false;
+ }
+
+ $expiration = $_SESSION['taler_turnstile_subscriptions'][$subscription_slug];
+ return $expiration >= time();
+ }
+
+ /**
+ * Grant session access to a specific post
+ *
+ * @param int $post_id The post ID
+ */
+ private static function grant_session_access($post_id) {
+ if (session_status() === PHP_SESSION_NONE) {
+ session_start();
+ }
+
+ if (!isset($_SESSION['taler_turnstile_access'])) {
+ $_SESSION['taler_turnstile_access'] = array();
+ }
+
+ $_SESSION['taler_turnstile_access'][$post_id] = true;
+ }
+
+ /**
+ * Check if session has access to a post
+ *
+ * @param int $post_id The post ID
+ * @return bool True if session has access
+ */
+ private static function has_session_access($post_id) {
+ if (session_status() === PHP_SESSION_NONE) {
+ session_start();
+ }
+
+ return isset($_SESSION['taler_turnstile_access'][$post_id]) &&
+ $_SESSION['taler_turnstile_access'][$post_id] === true;
+ }
+
+ /**
+ * Store order-to-node mapping in session
+ *
+ * @param int $post_id The post ID
+ * @param array $order_info Order information
+ */
+ private static function store_order_node_mapping($post_id, $order_info) {
+ if (session_status() === PHP_SESSION_NONE) {
+ session_start();
+ }
+
+ if (!isset($_SESSION['taler_turnstile_node_orders'])) {
+ $_SESSION['taler_turnstile_node_orders'] = array();
+ }
+
+ $_SESSION['taler_turnstile_node_orders'][$post_id] = $order_info;
+ }
+
+ /**
+ * Get order info for a post
+ *
+ * @param int $post_id The post ID
+ * @return array|null Order information or null
+ */
+ private static function get_node_order_info($post_id) {
+ if (session_status() === PHP_SESSION_NONE) {
+ session_start();
+ }
+
+ if (!isset($_SESSION['taler_turnstile_node_orders'][$post_id])) {
+ return null;
+ }
+
+ return $_SESSION['taler_turnstile_node_orders'][$post_id];
+ }
+}
+\ No newline at end of file
diff --git a/includes/class-field-manager.php b/includes/class-field-manager.php
@@ -0,0 +1,177 @@
+<?php
+/**
+ * Field Manager
+ *
+ * Manages the price category meta fields on post types.
+ */
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class Taler_Field_Manager {
+
+ /**
+ * Add price category fields to specified post types
+ *
+ * @param array $post_types Array of post type names
+ */
+ public static function add_fields_to_post_types($post_types) {
+ foreach ($post_types as $post_type) {
+ // Verify post type exists
+ if (!post_type_exists($post_type)) {
+ continue;
+ }
+
+ self::add_price_category_field($post_type);
+ }
+ }
+
+ /**
+ * Add price category field to a specific post type
+ *
+ * @param string $post_type The post type name
+ */
+ protected static function add_price_category_field($post_type) {
+ // Register the meta box for this post type
+ add_action('add_meta_boxes', function() use ($post_type) {
+ add_meta_box(
+ 'taler_turnstile_price_category',
+ __('Taler Price Category', 'taler-turnstile'),
+ array('Taler_Field_Manager', 'render_price_category_meta_box'),
+ $post_type,
+ 'side',
+ 'default'
+ );
+ });
+
+ // Register the meta field
+ register_post_meta($post_type, '_taler_price_category', array(
+ 'type' => 'string',
+ 'description' => __('Selected price category for this content', 'taler-turnstile'),
+ 'single' => true,
+ 'show_in_rest' => true,
+ 'sanitize_callback' => 'sanitize_key',
+ 'auth_callback' => function() {
+ return current_user_can('edit_posts');
+ }
+ ));
+
+ // Save the meta field
+ add_action('save_post_' . $post_type, array('Taler_Field_Manager', 'save_price_category_meta'), 10, 2);
+ }
+
+ /**
+ * Render the price category meta box
+ *
+ * @param WP_Post $post The post object
+ */
+ public static function render_price_category_meta_box($post) {
+ wp_nonce_field('taler_price_category_meta', 'taler_price_category_nonce');
+
+ $selected_category = get_post_meta($post->ID, '_taler_price_category', true);
+ $categories = Taler_Price_Category::get_all();
+ ?>
+ <p>
+ <label for="taler_price_category">
+ <?php esc_html_e('Price Category:', 'taler-turnstile'); ?>
+ </label>
+ <select name="taler_price_category" id="taler_price_category" style="width: 100%;">
+ <option value=""><?php esc_html_e('-- None --', 'taler-turnstile'); ?></option>
+ <?php foreach ($categories as $id => $category): ?>
+ <option value="<?php echo esc_attr($id); ?>" <?php selected($selected_category, $id); ?>>
+ <?php echo esc_html($category['label']); ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </p>
+ <p class="description">
+ <?php esc_html_e('Select a price category for this content. Leave empty for free access.', 'taler-turnstile'); ?>
+ </p>
+ <?php
+ }
+
+ /**
+ * Save the price category meta field
+ *
+ * @param int $post_id The post ID
+ * @param WP_Post $post The post object
+ */
+ public static function save_price_category_meta($post_id, $post) {
+ // Check nonce
+ if (!isset($_POST['taler_price_category_nonce']) ||
+ !wp_verify_nonce($_POST['taler_price_category_nonce'], 'taler_price_category_meta')) {
+ return;
+ }
+
+ // Check autosave
+ if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
+ return;
+ }
+
+ // Check permissions
+ if (!current_user_can('edit_post', $post_id)) {
+ return;
+ }
+
+ // Save or delete the meta field
+ if (isset($_POST['taler_price_category'])) {
+ $category = sanitize_key($_POST['taler_price_category']);
+
+ if (empty($category)) {
+ delete_post_meta($post_id, '_taler_price_category');
+ } else {
+ update_post_meta($post_id, '_taler_price_category', $category);
+ }
+ }
+ }
+
+ /**
+ * Remove price category fields from specified post types
+ *
+ * @param array $post_types Array of post type names
+ */
+ public static function remove_fields_from_post_types($post_types) {
+ foreach ($post_types as $post_type) {
+ self::remove_price_category_field($post_type);
+ }
+ }
+
+ /**
+ * Remove price category field from a specific post type
+ *
+ * @param string $post_type The post type name
+ */
+ protected static function remove_price_category_field($post_type) {
+ // Unregister the meta field
+ unregister_post_meta($post_type, '_taler_price_category');
+
+ // Note: Meta boxes are removed automatically when not registered
+ // We don't need to explicitly remove them since they're added via hooks
+ }
+
+ /**
+ * Cleanup all price category meta when no post types are using it
+ */
+ public static function cleanup_field_storage() {
+ global $wpdb;
+
+ // Delete all price category meta from all posts
+ $wpdb->delete(
+ $wpdb->postmeta,
+ array('meta_key' => '_taler_price_category'),
+ array('%s')
+ );
+ }
+
+ /**
+ * Initialize field manager hooks
+ */
+ public static function init() {
+ $enabled_types = get_option('taler_turnstile_enabled_post_types', array('post'));
+
+ if (!empty($enabled_types)) {
+ self::add_fields_to_post_types($enabled_types);
+ }
+ }
+}
+\ No newline at end of file
diff --git a/includes/class-price-category.php b/includes/class-price-category.php
@@ -0,0 +1,171 @@
+<?php
+/**
+ * Price Category Class
+ *
+ * Handles price category data and operations.
+ */
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class Taler_Price_Category {
+
+ /**
+ * Get all price categories
+ */
+ public static function get_all() {
+ return get_option('taler_turnstile_price_categories', array());
+ }
+
+ /**
+ * Get a specific price category by ID
+ */
+ public static function get($id) {
+ $categories = self::get_all();
+ return isset($categories[$id]) ? $categories[$id] : null;
+ }
+
+ /**
+ * Save a price category
+ */
+ public static function save($id, $data) {
+ $categories = self::get_all();
+ $categories[$id] = $data;
+ update_option('taler_turnstile_price_categories', $categories);
+
+ // Clear cache
+ wp_cache_delete('taler_payment_choices_' . $id, 'taler_turnstile');
+ }
+
+ /**
+ * Delete a price category
+ */
+ public static function delete($id) {
+ $categories = self::get_all();
+ if (isset($categories[$id])) {
+ unset($categories[$id]);
+ update_option('taler_turnstile_price_categories', $categories);
+ wp_cache_delete('taler_payment_choices_' . $id, 'taler_turnstile');
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Get subscriptions where price is zero (full access)
+ */
+ public static function get_full_subscriptions($prices) {
+ $subscriptions = array();
+
+ foreach ($prices as $token_family_slug => $currency_map) {
+ foreach ($currency_map as $currency_code => $price) {
+ if (is_numeric($price) && floatval($price) == 0.0) {
+ $subscriptions[] = $token_family_slug;
+ break;
+ }
+ }
+ }
+
+ return $subscriptions;
+ }
+
+ /**
+ * Get payment choices for GNU Taler v1 contracts
+ */
+ public static function get_payment_choices($id) {
+ $cache_key = 'taler_payment_choices_' . $id;
+ $cached = wp_cache_get($cache_key, 'taler_turnstile');
+
+ if ($cached !== false) {
+ return $cached;
+ }
+
+ $category = self::get($id);
+ if (!$category || !isset($category['prices'])) {
+ return array();
+ }
+
+ $choices = array();
+
+ foreach ($category['prices'] as $token_family_slug => $currency_map) {
+ foreach ($currency_map as $currency_code => $price) {
+ $inputs = array();
+
+ if ($token_family_slug !== '%none%') {
+ $inputs[] = array(
+ 'type' => 'token',
+ 'token_family_slug' => $token_family_slug,
+ 'count' => 1
+ );
+
+ $description = sprintf(
+ __('Pay in %s with subscription', 'taler-turnstile'),
+ $currency_code
+ );
+
+ $choices[] = array(
+ 'amount' => $currency_code . ':' . $price,
+ 'description' => $description,
+ 'inputs' => $inputs
+ );
+
+ // Check if subscription can be purchased
+ $subscription_price = self::get_subscription_price($token_family_slug, $currency_code);
+
+ if ($subscription_price !== null) {
+ $outputs = array(
+ array(
+ 'type' => 'token',
+ 'token_family_slug' => $token_family_slug,
+ 'count' => 1
+ )
+ );
+
+ $description = sprintf(
+ __('Buy subscription in %s', 'taler-turnstile'),
+ $currency_code
+ );
+
+ $total_price = floatval($subscription_price) + floatval($price);
+
+ $choices[] = array(
+ 'amount' => $currency_code . ':' . $total_price,
+ 'description' => $description,
+ 'outputs' => $outputs
+ );
+ }
+ } else {
+ // No subscription case
+ $description = sprintf(
+ __('Pay in %s', 'taler-turnstile'),
+ $currency_code
+ );
+
+ $choices[] = array(
+ 'amount' => $currency_code . ':' . floatval($price),
+ 'description' => $description,
+ 'inputs' => $inputs
+ );
+ }
+ }
+ }
+
+ wp_cache_set($cache_key, $choices, 'taler_turnstile', 3600);
+
+ return $choices;
+ }
+
+ /**
+ * Get subscription price for a token family and currency
+ */
+ private static function get_subscription_price($token_family_slug, $currency_code) {
+ $subscription_prices = get_option('taler_turnstile_subscription_prices', array());
+
+ if (isset($subscription_prices[$token_family_slug][$currency_code])) {
+ return $subscription_prices[$token_family_slug][$currency_code];
+ }
+
+ return null;
+ }
+}
+\ No newline at end of file
diff --git a/includes/class-taler-merchant-api.php b/includes/class-taler-merchant-api.php
@@ -0,0 +1,588 @@
+<?php
+/**
+ * Taler Merchant API Service
+ *
+ * Handles communication with the GNU Taler merchant backend.
+ */
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Taler error codes used in this module
+ */
+class Taler_Error_Code {
+ const TALER_EC_NONE = 0;
+ const TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN = 2000;
+ const TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN = 2005;
+}
+
+class Taler_Merchant_API {
+
+ /**
+ * How long are orders valid by default? 24h.
+ */
+ const ORDER_VALIDITY_SECONDS = 86400;
+
+ /**
+ * How long do we cache /config and token family data from the backend?
+ */
+ const CACHE_BACKEND_DATA_SECONDS = 60;
+
+ /**
+ * Return the base URL for the given backend URL (without instance!)
+ *
+ * @param string $backend_url Backend URL to check, may include '/instances/$ID' path
+ * @return string|null Base URL, or NULL if the backend URL is invalid
+ */
+ private static function get_base_url($backend_url) {
+ if (empty($backend_url)) {
+ return null;
+ }
+
+ if (!str_ends_with($backend_url, '/')) {
+ return null;
+ }
+
+ $parsed_url = parse_url($backend_url);
+ $path = isset($parsed_url['path']) ? $parsed_url['path'] : '/';
+ $cleaned_path = preg_replace('#^/instances/[^/]+/?#', '/', $path);
+
+ $base = $parsed_url['scheme'] . '://' . $parsed_url['host'];
+
+ if (isset($parsed_url['port'])) {
+ $base .= ':' . $parsed_url['port'];
+ }
+
+ return $base . $cleaned_path;
+ }
+
+ /**
+ * Check if the payment backend URL is valid
+ *
+ * @param string $url Backend URL to check
+ * @return bool TRUE if this is a valid backend URL for a Taler backend
+ */
+ public static function check_config($url) {
+ $base_url = self::get_base_url($url);
+
+ if ($base_url === null) {
+ return false;
+ }
+
+ try {
+ $response = wp_remote_get($base_url . 'config', array(
+ 'timeout' => 5,
+ 'redirection' => 5
+ ));
+
+ if (is_wp_error($response)) {
+ return false;
+ }
+
+ if (wp_remote_retrieve_response_code($response) !== 200) {
+ return false;
+ }
+
+ $body = wp_remote_retrieve_body($response);
+ $data = json_decode($body, true);
+
+ return isset($data['name']) && $data['name'] === 'taler-merchant';
+ } catch (Exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Check access to the merchant backend
+ *
+ * @param string $backend_url Backend URL to check
+ * @param string $access_token Access token to talk to the instance
+ * @return int HTTP status code (200/204 if successful, 0 on error)
+ */
+ public static function check_access($backend_url, $access_token) {
+ try {
+ $args = array(
+ 'timeout' => 5,
+ 'redirection' => 5,
+ 'headers' => array()
+ );
+
+ if (!empty($access_token)) {
+ $args['headers']['Authorization'] = 'Bearer ' . $access_token;
+ }
+
+ $response = wp_remote_get($backend_url . 'private/orders?limit=1', $args);
+
+ if (is_wp_error($response)) {
+ return 0;
+ }
+
+ return wp_remote_retrieve_response_code($response);
+ } catch (Exception $e) {
+ return 0;
+ }
+ }
+
+ /**
+ * Get available subscriptions from the backend
+ *
+ * @return array Array mapping token family IDs to subscription data
+ */
+ public static function get_subscriptions() {
+ $cache_key = 'taler_turnstile_subscriptions';
+ $cached = get_transient($cache_key);
+
+ if ($cached !== false) {
+ return $cached;
+ }
+
+ // Default: always include "no subscription" option
+ $result = array(
+ '%none%' => array(
+ 'name' => 'none',
+ 'label' => __('No reduction', 'taler-turnstile'),
+ 'description' => __('No subscription', 'taler-turnstile'),
+ 'description_i18n' => self::build_translation_map(__('No subscription', 'taler-turnstile'))
+ )
+ );
+
+ $backend_url = get_option('taler_turnstile_payment_backend_url');
+ $access_token = get_option('taler_turnstile_access_token');
+
+ if (empty($backend_url) || empty($access_token)) {
+ error_log('No GNU Taler Turnstile backend configured, returning "none" for subscriptions.');
+ return $result;
+ }
+
+ try {
+ $args = array(
+ 'timeout' => 5,
+ 'redirection' => 5,
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $access_token
+ )
+ );
+
+ $response = wp_remote_get($backend_url . 'private/tokenfamilies', $args);
+
+ if (is_wp_error($response)) {
+ error_log('Failed to obtain token family list: ' . $response->get_error_message());
+ return $result;
+ }
+
+ $http_status = wp_remote_retrieve_response_code($response);
+ $body = wp_remote_retrieve_body($response);
+ $jbody = json_decode($body, true);
+
+ switch ($http_status) {
+ case 200:
+ if (!isset($jbody['token_families'])) {
+ error_log('Failed to obtain token family list: HTTP success response unexpectedly lacks "token_families" field.');
+ return $result;
+ }
+ break;
+
+ case 204:
+ // Empty list
+ set_transient($cache_key, $result, self::CACHE_BACKEND_DATA_SECONDS);
+ return $result;
+
+ case 403:
+ error_log('Access denied by the merchant backend. Check your GNU Taler Turnstile configuration!');
+ return $result;
+
+ case 404:
+ error_log('Failed to fetch token family list: ' . json_encode($jbody));
+ return $result;
+
+ default:
+ error_log('Unexpected HTTP status code ' . $http_status . ' trying to fetch token family list');
+ return $result;
+ }
+
+ $token_families = $jbody['token_families'];
+
+ foreach ($token_families as $family) {
+ if (isset($family['kind']) && $family['kind'] === 'subscription') {
+ $slug = $family['slug'];
+ $result[$slug] = array(
+ 'name' => $family['name'],
+ 'label' => $slug,
+ 'description' => $family['description'],
+ 'description_i18n' => isset($family['description_i18n']) ? $family['description_i18n'] : array()
+ );
+ }
+ }
+
+ set_transient($cache_key, $result, self::CACHE_BACKEND_DATA_SECONDS);
+ return $result;
+
+ } catch (Exception $e) {
+ error_log('Failed to obtain list of token families: ' . $e->getMessage());
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get available currencies from the backend
+ *
+ * @return array Array of currencies with code, name, label, and step
+ */
+ public static function get_currencies() {
+ $cache_key = 'taler_turnstile_currencies';
+ $cached = get_transient($cache_key);
+
+ if ($cached !== false) {
+ return $cached;
+ }
+
+ $backend_url = get_option('taler_turnstile_payment_backend_url');
+
+ if (empty($backend_url)) {
+ error_log('Taler merchant backend not configured; cannot obtain currency list');
+ return array();
+ }
+
+ try {
+ $config_url = $backend_url . 'config';
+ $response = wp_remote_get($config_url, array(
+ 'timeout' => 5,
+ 'redirection' => 5
+ ));
+
+ if (is_wp_error($response)) {
+ error_log('Failed to fetch backend config: ' . $response->get_error_message());
+ return array();
+ }
+
+ if (wp_remote_retrieve_response_code($response) !== 200) {
+ error_log('Taler merchant backend did not respond; cannot obtain currency list');
+ return array();
+ }
+
+ $body = wp_remote_retrieve_body($response);
+ $backend_config = json_decode($body, true);
+
+ if (!$backend_config || !is_array($backend_config)) {
+ error_log('Taler merchant backend returned invalid /config response');
+ return array();
+ }
+
+ if (!isset($backend_config['currencies'])) {
+ error_log('Backend returned malformed response for /config');
+ return array();
+ }
+
+ $currencies = $backend_config['currencies'];
+
+ $result = array_map(function($currency) {
+ return array(
+ 'code' => $currency['currency'],
+ 'name' => $currency['name'],
+ 'label' => isset($currency['alt_unit_names'][0]) ? $currency['alt_unit_names'][0] : $currency['currency'],
+ 'step' => pow(0.1, isset($currency['num_fractional_input_digits']) ? $currency['num_fractional_input_digits'] : 2)
+ );
+ }, $currencies);
+
+ set_transient($cache_key, $result, self::CACHE_BACKEND_DATA_SECONDS);
+ return $result;
+
+ } catch (Exception $e) {
+ error_log('Failed to obtain configuration from backend: ' . $e->getMessage());
+ return array();
+ }
+ }
+
+ /**
+ * Check order status with Taler backend
+ *
+ * @param string $order_id The order ID to check
+ * @return array|false Order status information or false on failure
+ */
+ public static function check_order_status($order_id) {
+ $backend_url = get_option('taler_turnstile_payment_backend_url');
+ $access_token = get_option('taler_turnstile_access_token');
+
+ if (empty($backend_url) || empty($access_token)) {
+ error_log('No GNU Taler Turnstile backend configured, cannot check order status!');
+ return false;
+ }
+
+ try {
+ $args = array(
+ 'timeout' => 5,
+ 'redirection' => 5,
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $access_token
+ )
+ );
+
+ $response = wp_remote_get($backend_url . 'private/orders/' . $order_id, $args);
+
+ if (is_wp_error($response)) {
+ error_log('Failed to check order status: ' . $response->get_error_message());
+ return false;
+ }
+
+ $http_status = wp_remote_retrieve_response_code($response);
+ $body = wp_remote_retrieve_body($response);
+ $jbody = json_decode($body, true);
+
+ switch ($http_status) {
+ case 200:
+ // Success
+ break;
+
+ case 403:
+ error_log('Access denied by the merchant backend. Check your GNU Taler Turnstile configuration!');
+ return false;
+
+ case 404:
+ $ec = isset($jbody['code']) ? $jbody['code'] : Taler_Error_Code::TALER_EC_NONE;
+
+ switch ($ec) {
+ case Taler_Error_Code::TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN:
+ error_log('Configured instance unknown to merchant backend. Check your configuration!');
+ return false;
+
+ case Taler_Error_Code::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN:
+ error_log('Order ' . $order_id . ' disappeared in the backend.');
+ return false;
+
+ default:
+ error_log('Unexpected error checking order status: ' . json_encode($jbody));
+ return false;
+ }
+
+ default:
+ error_log('Unexpected HTTP status code ' . $http_status . ' from merchant backend');
+ return false;
+ }
+
+ $order_status = isset($jbody['order_status']) ? $jbody['order_status'] : 'unknown';
+ $subscription_expiration = 0;
+ $subscription_slug = false;
+ $pay_deadline = 0;
+ $paid = false;
+
+ switch ($order_status) {
+ case 'unpaid':
+ $pay_deadline = isset($jbody['pay_deadline']['t_s'])
+ ? $jbody['pay_deadline']['t_s']
+ : (self::ORDER_VALIDITY_SECONDS + (isset($jbody['creation_time']['t_s']) ? $jbody['creation_time']['t_s'] : 0));
+ break;
+
+ case 'claimed':
+ $contract_terms = $jbody['contract_terms'];
+ $pay_deadline = isset($contract_terms['pay_deadline']['t_s']) ? $contract_terms['pay_deadline']['t_s'] : 0;
+ break;
+
+ case 'paid':
+ $paid = true;
+ $contract_terms = $jbody['contract_terms'];
+ $contract_version = isset($jbody['version']) ? $jbody['version'] : 0;
+ $now = time();
+
+ if ($contract_version === 1) {
+ $choice_index = isset($jbody['choice_index']) ? $jbody['choice_index'] : 0;
+ $token_families = $contract_terms['token_families'];
+ $contract_choice = $contract_terms['choices'][$choice_index];
+ $outputs = isset($contract_choice['outputs']) ? $contract_choice['outputs'] : array();
+
+ foreach ($outputs as $output) {
+ $slug = $output['token_family_slug'];
+ $token_family = $token_families[$slug];
+ $details = $token_family['details'];
+
+ if (isset($details['class']) && $details['class'] !== 'subscription') {
+ continue;
+ }
+
+ $keys = $token_family['keys'];
+
+ foreach ($keys as $key) {
+ $sig_start = $key['signature_validity_start']['t_s'];
+ $sig_end = $key['signature_validity_end']['t_s'];
+
+ if ($sig_start <= $now && $sig_end > $now) {
+ $subscription_slug = $slug;
+ $subscription_expiration = $sig_end;
+ break 2;
+ }
+ }
+ }
+ }
+ break;
+
+ default:
+ error_log('Got unexpected order status: ' . $order_status);
+ break;
+ }
+
+ return array(
+ 'order_id' => $order_id,
+ 'paid' => $paid,
+ 'subscription_slug' => $subscription_slug,
+ 'subscription_expiration' => $subscription_expiration,
+ 'order_expiration' => $pay_deadline
+ );
+
+ } catch (Exception $e) {
+ error_log('Failed to check order status: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Create a new Taler order
+ *
+ * @param int $post_id The post ID to create an order for
+ * @return array|false Order information or false on failure
+ */
+ public static function create_order($post_id) {
+ $backend_url = get_option('taler_turnstile_payment_backend_url');
+ $access_token = get_option('taler_turnstile_access_token');
+
+ if (empty($backend_url) || empty($access_token)) {
+ error_log('No backend, cannot setup new order');
+ return false;
+ }
+
+ $price_category_id = get_post_meta($post_id, '_taler_price_category', true);
+
+ if (empty($price_category_id)) {
+ error_log('No price category selected');
+ return false;
+ }
+
+ $price_category = Taler_Price_Category::get($price_category_id);
+
+ if (!$price_category) {
+ error_log('No price category, cannot setup new order');
+ return false;
+ }
+
+ $choices = Taler_Price_Category::get_payment_choices($price_category_id);
+
+ if (empty($choices)) {
+ error_log('Price list is empty, cannot setup new order');
+ return false;
+ }
+
+ $fulfillment_url = get_permalink($post_id);
+ $hashed_session_id = self::get_hashed_session_id();
+
+ $order_expiration = time() + self::ORDER_VALIDITY_SECONDS;
+
+ $order_data = array(
+ 'order' => array(
+ 'version' => 1,
+ 'choices' => $choices,
+ 'summary' => 'Access to: ' . get_the_title($post_id),
+ 'fulfillment_url' => $fulfillment_url,
+ 'pay_deadline' => array(
+ 't_s' => $order_expiration
+ )
+ ),
+ 'session_id' => $hashed_session_id,
+ 'create_token' => false
+ );
+
+ try {
+ $args = array(
+ 'timeout' => 5,
+ 'redirection' => 5,
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $access_token,
+ 'Content-Type' => 'application/json'
+ ),
+ 'body' => json_encode($order_data)
+ );
+
+ $response = wp_remote_post($backend_url . 'private/orders', $args);
+
+ if (is_wp_error($response)) {
+ error_log('Failed to create Taler order: ' . $response->get_error_message());
+ return false;
+ }
+
+ $http_status = wp_remote_retrieve_response_code($response);
+ $body = wp_remote_retrieve_body($response);
+ $jbody = json_decode($body, true);
+
+ switch ($http_status) {
+ case 200:
+ if (!isset($jbody['order_id'])) {
+ error_log('Failed to create order: response lacks "order_id" field.');
+ return false;
+ }
+ break;
+
+ case 403:
+ error_log('Access denied by the merchant backend. Check your configuration!');
+ return false;
+
+ case 451:
+ error_log('Failed to create order as legitimization is required first.');
+ return false;
+
+ default:
+ error_log('Unexpected HTTP status code ' . $http_status . ' trying to create order');
+ return false;
+ }
+
+ $order_id = $jbody['order_id'];
+
+ return array(
+ 'order_id' => $order_id,
+ 'payment_url' => $backend_url . 'orders/' . $order_id,
+ 'order_expiration' => $order_expiration,
+ 'paid' => false,
+ 'session_id' => $hashed_session_id
+ );
+
+ } catch (Exception $e) {
+ error_log('Failed to create Taler order: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Build a translation map for all enabled languages
+ *
+ * @param string $string The translatable string
+ * @return array Map of language codes to translated strings
+ */
+ private static function build_translation_map($string) {
+ // WordPress doesn't have built-in multi-language support like Drupal
+ // This would need to be extended with WPML or Polylang integration
+ return array(
+ 'en' => $string
+ );
+ }
+
+ /**
+ * Generate a hashed session identifier for payment tracking
+ *
+ * @return string Base64-encoded SHA-256 hash of the session ID (URL-safe)
+ */
+ private static function get_hashed_session_id() {
+ if (session_status() === PHP_SESSION_NONE) {
+ session_start();
+ }
+
+ $raw_session_id = session_id();
+
+ if (empty($raw_session_id)) {
+ $raw_session_id = wp_get_session_token();
+ }
+
+ $hash = hash('sha256', $raw_session_id, true);
+
+ // Encode as URL-safe base64
+ return rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');
+ }
+}
+\ No newline at end of file
diff --git a/taler-turnstile.php b/taler-turnstile.php
@@ -0,0 +1,194 @@
+<?php
+/**
+ * Plugin Name: GNU Taler Turnstile
+ * Plugin URI: https://taler.net
+ * Description: Adds price fields to posts and requires payment for access via GNU Taler.
+ * Version: 0.9.0
+ * Author: GNU Taler
+ * Author URI: https://taler.net
+ * License: GPL v3 or later
+ * Text Domain: taler-turnstile
+ * Domain Path: /languages
+ */
+
+// Exit if accessed directly
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+// Define plugin constants
+define('TALER_TURNSTILE_VERSION', '0.9.0');
+define('TALER_TURNSTILE_PLUGIN_DIR', plugin_dir_path(__FILE__));
+define('TALER_TURNSTILE_PLUGIN_URL', plugin_dir_url(__FILE__));
+
+// Include required files
+require_once TALER_TURNSTILE_PLUGIN_DIR . 'includes/class-taler-merchant-api.php';
+require_once TALER_TURNSTILE_PLUGIN_DIR . 'includes/class-price-category.php';
+require_once TALER_TURNSTILE_PLUGIN_DIR . 'includes/class-field-manager.php';
+require_once TALER_TURNSTILE_PLUGIN_DIR . 'includes/class-content-filter.php';
+require_once TALER_TURNSTILE_PLUGIN_DIR . 'admin/class-admin-settings.php';
+require_once TALER_TURNSTILE_PLUGIN_DIR . 'admin/class-price-category-admin.php';
+require_once TALER_TURNSTILE_PLUGIN_DIR . 'admin/class-subscription-prices-admin.php';
+
+/**
+ * Main plugin class
+ */
+class Taler_Turnstile {
+
+ private static $instance = null;
+
+ public static function get_instance() {
+ if (null === self::$instance) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ private function __construct() {
+ // Initialize plugin
+ add_action('plugins_loaded', array($this, 'init'));
+ add_action('admin_menu', array($this, 'add_admin_menu'));
+ add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_scripts'));
+ add_action('wp_enqueue_scripts', array($this, 'enqueue_frontend_scripts'));
+
+ // Activation and deactivation hooks
+ register_activation_hook(__FILE__, array($this, 'activate'));
+ register_deactivation_hook(__FILE__, array($this, 'deactivate'));
+ }
+
+ public function init() {
+ load_plugin_textdomain('taler-turnstile', false, dirname(plugin_basename(__FILE__)) . '/languages');
+
+ // Initialize field manager
+ Taler_Field_Manager::init();
+
+ // Initialize content filter
+ Taler_Content_Filter::init();
+
+ // Initialize admin classes
+ if (is_admin()) {
+ new Taler_Turnstile_Admin_Settings();
+ new Taler_Turnstile_Price_Category_Admin();
+ new Taler_Subscription_Prices_Admin();
+ }
+ }
+
+ public function add_admin_menu() {
+ // Main settings page
+ add_options_page(
+ __('GNU Taler Turnstile Settings', 'taler-turnstile'),
+ __('Taler Turnstile', 'taler-turnstile'),
+ 'manage_options',
+ 'taler-turnstile-settings',
+ array('Taler_Turnstile_Admin_Settings', 'render_settings_page')
+ );
+
+ // Subscription prices page
+ add_submenu_page(
+ 'options-general.php',
+ __('Taler Subscription Prices', 'taler-turnstile'),
+ __('Taler Subscriptions', 'taler-turnstile'),
+ 'manage_options',
+ 'taler-subscription-prices',
+ array('Taler_Subscription_Prices_Admin', 'render_settings_page')
+ );
+
+ // Price categories management
+ add_menu_page(
+ __('Taler Price Categories', 'taler-turnstile'),
+ __('Taler Prices', 'taler-turnstile'),
+ 'manage_options',
+ 'taler-price-categories',
+ array('Taler_Turnstile_Price_Category_Admin', 'render_list_page'),
+ 'dashicons-money-alt',
+ 30
+ );
+
+ add_submenu_page(
+ 'taler-price-categories',
+ __('Add Price Category', 'taler-turnstile'),
+ __('Add New', 'taler-turnstile'),
+ 'manage_options',
+ 'taler-price-category-add',
+ array('Taler_Turnstile_Price_Category_Admin', 'render_edit_page')
+ );
+ }
+
+ public function enqueue_admin_scripts($hook) {
+ if (strpos($hook, 'taler') === false) {
+ return;
+ }
+
+ wp_enqueue_style(
+ 'taler-turnstile-admin',
+ TALER_TURNSTILE_PLUGIN_URL . 'assets/css/admin.css',
+ array(),
+ TALER_TURNSTILE_VERSION
+ );
+
+ wp_enqueue_script(
+ 'taler-turnstile-admin',
+ TALER_TURNSTILE_PLUGIN_URL . 'assets/js/admin.js',
+ array('jquery'),
+ TALER_TURNSTILE_VERSION,
+ true
+ );
+ }
+
+ public function enqueue_frontend_scripts() {
+ // Only enqueue on singular posts that might have paywall
+ if (!is_singular()) {
+ return;
+ }
+
+ $post_id = get_the_ID();
+ $price_category = get_post_meta($post_id, '_taler_price_category', true);
+
+ // Only load scripts if this post has a price category
+ if (empty($price_category)) {
+ return;
+ }
+
+ // Enqueue QRCode library
+ wp_enqueue_script(
+ 'qrcode',
+ TALER_TURNSTILE_PLUGIN_URL . 'assets/js/qrcode.min.js',
+ array(),
+ '1.0.0',
+ true
+ );
+
+ // Enqueue payment button script
+ wp_enqueue_script(
+ 'taler-payment-button',
+ TALER_TURNSTILE_PLUGIN_URL . 'assets/js/payment-button.js',
+ array('jquery', 'qrcode'),
+ TALER_TURNSTILE_VERSION,
+ true
+ );
+
+ // Enqueue frontend styles
+ wp_enqueue_style(
+ 'taler-turnstile-frontend',
+ TALER_TURNSTILE_PLUGIN_URL . 'assets/css/frontend.css',
+ array(),
+ TALER_TURNSTILE_VERSION
+ );
+ }
+
+ public function activate() {
+ // Set default options
+ add_option('taler_turnstile_enabled_post_types', array('post'));
+ add_option('taler_turnstile_payment_backend_url', '');
+ add_option('taler_turnstile_access_token', '');
+ add_option('taler_turnstile_grant_access_on_error', false);
+ add_option('taler_turnstile_subscription_prices', array());
+ }
+
+ public function deactivate() {
+ // Cleanup if needed
+ }
+}
+
+// Initialize the plugin
+Taler_Turnstile::get_instance();