commit c61b2eaef559777e56aa7aec9a1ea7e20dc2b1c8
parent 2e05c96a07d702afa9dab1cf3340c35c93b4bf53
Author: Christian Grothoff <christian@grothoff.org>
Date: Sun, 19 Oct 2025 15:30:24 +0200
move subscription price configuration into a separate form
Diffstat:
5 files changed, 422 insertions(+), 102 deletions(-)
diff --git a/src/Form/SubscriptionPricesForm.php b/src/Form/SubscriptionPricesForm.php
@@ -0,0 +1,187 @@
+<?php
+
+namespace Drupal\turnstile\Form;
+
+use Drupal\Core\Form\ConfigFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Url;
+use Drupal\turnstile\TalerMerchantApiService;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Configure subscription prices.
+ */
+class SubscriptionPricesForm extends ConfigFormBase {
+
+ /**
+ * The Taler Merchant API service.
+ *
+ * @var \Drupal\turnstile\TalerMerchantApiService
+ */
+ protected $apiService;
+
+ /**
+ * Constructs a SubscriptionPricesForm object.
+ *
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+ * The factory for configuration objects.
+ * @param \Drupal\turnstile\TalerMerchantApiService $api_service
+ * The API service.
+ */
+ public function __construct(ConfigFactoryInterface $config_factory, TalerMerchantApiService $api_service) {
+ parent::__construct($config_factory);
+ $this->apiService = $api_service;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('config.factory'),
+ $container->get('turnstile.api_service')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames() {
+ return ['turnstile.settings'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'turnstile_subscription_prices_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $config = $this->config('turnstile.settings');
+
+ // Check if backend is configured
+ $backend_url = $config->get('payment_backend_url');
+ $access_token = $config->get('access_token');
+
+ if (empty($backend_url) || empty($access_token)) {
+ $this->messenger()->addError(
+ $this->t('Payment backend is not configured. Please <a href="@url">configure the backend</a> first.', [
+ '@url' => Url::fromRoute('turnstile.settings')->toString(),
+ ])
+ );
+ return $form;
+ }
+
+ $form['description'] = [
+ '#type' => 'item',
+ '#markup' => $this->t('<p>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.</p>'),
+ ];
+
+ // Get subscriptions and currencies from API.
+ $subscriptions = $this->apiService->getSubscriptions();
+ $currencies = $this->apiService->getCurrencies();
+
+ if (empty($currencies)) {
+ $this->messenger()->addError($this->t('Unable to load currencies from the API. Please check your backend configuration.'));
+ return $form;
+ }
+
+ if (empty($subscriptions)) {
+ $this->messenger()->addWarning($this->t('No subscriptions configured in Taler merchant backend.'));
+ return $form;
+ }
+
+ $form['subscription_prices'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Subscription Prices'),
+ '#description' => $this->t('Set the price for buying each subscription type in different currencies.'),
+ '#tree' => TRUE,
+ ];
+
+ $existing_prices = $config->get('subscription_prices') ?: [];
+
+ foreach ($subscriptions as $subscription_id => $subscription) {
+ $subscription_label = $subscription['label'] ?? $subscription['name'];
+
+ // Skip the %none% case as you can't buy "no subscription"
+ if ($subscription_id === '%none%') {
+ continue;
+ }
+
+ $form['subscription_prices'][$subscription_id] = [
+ '#type' => 'details',
+ '#title' => $subscription_label,
+ '#description' => $subscription['description'] ?? '',
+ '#open' => TRUE,
+ ];
+
+ foreach ($currencies as $currency) {
+ $currency_code = $currency['code'];
+ $currency_label = $currency['label'] ?? $currency['code'];
+
+ $form['subscription_prices'][$subscription_id][$currency_code] = [
+ '#type' => 'number',
+ '#title' => $currency_label,
+ '#default_value' => $existing_prices[$subscription_id][$currency_code] ?? '',
+ '#min' => 0,
+ '#step' => $currency['step'] ?? 0.01,
+ '#size' => 20,
+ '#description' => $this->t('Leave empty to prevent buying this subscription with @currency.', [
+ '@currency' => $currency_code,
+ ]),
+ ];
+ }
+ }
+
+ return parent::buildForm($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ parent::validateForm($form, $form_state);
+
+ // Validate subscription prices
+ $subscription_prices = $form_state->getValue('subscription_prices');
+ if (is_array($subscription_prices)) {
+ foreach ($subscription_prices as $subscription_id => $currencies) {
+ if (is_array($currencies)) {
+ foreach ($currencies as $currency_code => $price) {
+ // Skip empty values as they are allowed
+ if ($price === '' || $price === NULL) {
+ continue;
+ }
+
+ // Validate that the price is a valid non-negative number
+ if (!is_numeric($price) || $price < 0) {
+ $form_state->setErrorByName(
+ "subscription_prices][{$subscription_id}][{$currency_code}",
+ $this->t('Subscription prices must be non-negative numbers.')
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $subscription_prices = $form_state->getValue('subscription_prices');
+
+ $this->config('turnstile.settings')
+ ->set('subscription_prices', $subscription_prices)
+ ->save();
+
+ parent::submitForm($form, $form_state);
+ }
+
+}
+\ No newline at end of file
diff --git a/src/Form/TurnstileSettingsForm.php b/src/Form/TurnstileSettingsForm.php
@@ -39,7 +39,7 @@ class TurnstileSettingsForm extends ConfigFormBase {
*
* @var \Drupal\turnstile\TalerMerchantApiService
*/
- protected $apiService;
+ protected $apiService; // FIXME: now dead, but maybe move /config and /private/orders requests into here?
/**
* Constructs a TurnstileSettingsForm object.
@@ -130,6 +130,7 @@ class TurnstileSettingsForm extends ConfigFormBase {
'#default_value' => $config->get('grant_access_on_error') ?: '',
];
+if (FALSE) {
// Get subscriptions and currencies from API.
$subscriptions = $this->apiService->getSubscriptions();
$currencies = $this->apiService->getCurrencies();
@@ -177,16 +178,128 @@ class TurnstileSettingsForm extends ConfigFormBase {
}
}
+
+ if ($this->isBackendConfigured()) {
+ $form['subscription_prices_link'] = [
+ '#type' => 'item',
+ '#title' => $this->t('Subscription Prices'),
+ '#markup' => $this->t('<p><a href="@url" class="button">Configure Subscription Prices</a></p>', [
+ '@url' => Url::fromRoute('turnstile.subscription_prices')->toString(),
+ ]),
+ ];
+ } else {
+ $form['subscription_prices_link'] = [
+ '#type' => 'item',
+ '#title' => $this->t('Subscription Prices'),
+ '#markup' => $this->t('<p>You must configure and save the payment backend settings above before configuring subscription prices.</p>'),
+ ];
+ }
+}
+
return parent::buildForm($form, $form_state);
}
/**
+ * Return the base URL of the backend (without instance!)
+ *
+ * @return string|null
+ * base URL, or NULL if the backend is unconfigured
+ */
+ private function getBaseURL() {
+ $config = $this->config('turnstile.settings');
+ $backend_url = $config->get('payment_backend_url');
+ if (empty($backend_url)) {
+ return NULL;
+ }
+ if (!str_ends_with($backend_url, '/')) {
+ return NULL;
+ }
+ $parsed_url = parse_url($backend_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 base URL is properly configured.
+ *
+ * @return bool
+ * TRUE if backend is configured and accessible.
+ */
+ private function isBaseURLConfigured() {
+ $base_url = $this->getBaseURL();
+
+ if (NULL === $base_url) {
+ return FALSE;
+ }
+
+ try {
+ $client = \Drupal::httpClient();
+ $response = $client->get($base_url . 'config', [
+ 'allow_redirects' => TRUE,
+ 'http_errors' => FALSE,
+ 'timeout' => 5,
+ ]);
+ if ($response->getStatusCode() !== 200) {
+ return FALSE;
+ }
+ $body = json_decode($response->getBody(), TRUE);
+ return isset($body['name']) && $body['name'] === 'taler-merchant';
+ } catch (\Exception $e) {
+ return FALSE;
+ }
+ }
+
+
+ /**
+ * Check if the backend is properly configured.
+ *
+ * @return int
+ * 200 or 204 if backend is configured and accessible,
+ * 0 on other error, otherwise HTTP status code indicating error
+ */
+ private function isBackendConfigured() {
+ if (!$this->isBaseURLConfigured()) {
+ return 0;
+ }
+
+ $config = $this->config('turnstile.settings');
+ $backend_url = $config->get('payment_backend_url');
+ $access_token = $config->get('access_token');
+
+ try {
+ $client = \Drupal::httpClient();
+ $response = $client->get(
+ $payment_backend_url . 'private/orders?limit=1',
+ [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $access_token,
+ ],
+ 'allow_redirects' => TRUE,
+ 'http_errors' => FALSE,
+ 'timeout' => 5, // seconds
+ ]
+ );
+ return $response->getStatusCode();
+ } catch (\Exception $e) {
+ return 0;
+ }
+ }
+
+
+ /**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
parent::validateForm($form, $form_state);
+if (FALSE) {
// Validate subscription prices
$subscription_prices = $form_state->getValue('subscription_prices');
if (is_array($subscription_prices)) {
@@ -209,13 +322,14 @@ class TurnstileSettingsForm extends ConfigFormBase {
}
}
}
+}
// Test the access token and backend URL.
$payment_backend_url = $form_state->getValue('payment_backend_url');
$access_token = $form_state->getValue('access_token');
if ( (!empty($payment_backend_url)) &&
- (! str_ends_with($payment_backend_url, '/')) )
+ (!str_ends_with($payment_backend_url, '/')) )
{
$form_state->setErrorByName('payment_backend_url',
$this->t('Payment backend URL must end with a "/".'));
@@ -224,7 +338,7 @@ class TurnstileSettingsForm extends ConfigFormBase {
}
if ( (!empty($access_token)) &&
- (! str_starts_with($access_token, 'secret-token:')) )
+ (!str_starts_with($access_token, 'secret-token:')) )
{
$form_state->setErrorByName('payment_backend_url');
$form_state->setErrorByName('access_token',
@@ -232,99 +346,56 @@ class TurnstileSettingsForm extends ConfigFormBase {
return;
}
- if (!empty($payment_backend_url)) {
- $parsed_url = parse_url($payment_backend_url);
- $path = $parsed_url['path'];
- // Remove "instances/$INSTANCE_ID/" to get the base URL (if present)
- $cleaned_path = preg_replace('#^/instances/[^/]+/?#', '/', $path);
- $base = $parsed_url['scheme'] . '://' . $parsed_url['host'];
- $base_url = $base . $cleaned_path;
-
- try {
- $client = \Drupal::httpClient();
- $response = $client->get(
- $base_url . 'config',
- [
- 'allow_redirects' => TRUE,
- 'http_errors' => FALSE,
- 'timeout' => 5, // seconds
- ]);
- if ( ($response->getStatusCode() !== 200) ||
- (json_decode($response->getBody(), TRUE)['name']
- != 'taler-merchant') ) {
- $form_state->setErrorByName('payment_backend_url',
- $this->t('Invalid payment backend URL'));
- $form_state->setErrorByName('access_token');
- return;
- }
- }
- catch (\Exception $e) {
- $form_state->setErrorByName('payment_backend_url',
- $this->t('HTTP request failed:' . $e));
- $form_state->setErrorByName('access_token');
- return;
- }
+ if (! $this->isBaseURLConfigure()) {
+ $form_state->setErrorByName('payment_backend_url',
+ $this->t('Invalid payment backend URL'));
+ $form_state->setErrorByName('access_token');
+ return;
}
- if (!empty($payment_backend_url) && !empty($access_token)) {
- try {
- $client = \Drupal::httpClient();
- $response = $client->get(
- $payment_backend_url . 'private/orders?limit=1',
- [
- 'headers' => [
- 'Authorization' => 'Bearer ' . $access_token,
- ],
- 'allow_redirects' => TRUE,
- 'http_errors' => FALSE,
- 'timeout' => 5, // seconds
- ]
- );
- switch ($response->getStatusCode()) {
- case 502:
- $form_state->setErrorByName('payment_backend_url',
- $this->t('Bad gateway (502) trying to access the merchant backend'));
- $form_state->setErrorByName('access_token');
- return;
- case 500:
- $form_state->setErrorByName('payment_backend_url',
- $this->t('Internal server error (500) of the merchant backend reported'));
- $form_state->setErrorByName('access_token');
- return;
- case 404:
- $form_state->setErrorByName('payment_backend_url',
- $this->t('The specified instance is unknown to the merchant backend'));
- $form_state->setErrorByName('access_token');
- return;
- case 403:
- $form_state->setErrorByName('payment_backend_url');
- $form_state->setErrorByName('access_token',
- $this->t('Access token not accepted by the merchant backend'));
- return;
- case 401:
- $form_state->setErrorByName('payment_backend_url');
- $form_state->setErrorByName('access_token',
- $this->t('Access token not accepted by the merchant backend'));
- return;
- case 204:
- // Empty order list is OK
- break;
- case 200:
- // Success is great
- break;
- default:
- $form_state->setErrorByName('payment_backend_url',
- $this->t('Unexpected response (' . $response->getStatusCode() . ') from merchant backend'));
- $form_state->setErrorByName('access_token');
- return;
- }
- }
- catch (\Exception $e) {
+ $http_status = $this->isBackendConfigured();
+ switch ($http_status) {
+ case 502:
$form_state->setErrorByName('payment_backend_url',
- $this->t('HTTP request failed:' . $e));
+ $this->t('Bad gateway (502) trying to access the merchant backend'));
$form_state->setErrorByName('access_token');
return;
- }
- }
+ case 500:
+ $form_state->setErrorByName('payment_backend_url',
+ $this->t('Internal server error (500) of the merchant backend reported'));
+ $form_state->setErrorByName('access_token');
+ return;
+ case 404:
+ $form_state->setErrorByName('payment_backend_url',
+ $this->t('The specified instance is unknown to the merchant backend'));
+ $form_state->setErrorByName('access_token');
+ return;
+ case 403:
+ $form_state->setErrorByName('payment_backend_url');
+ $form_state->setErrorByName('access_token',
+ $this->t('Access token not accepted by the merchant backend'));
+ return;
+ case 401:
+ $form_state->setErrorByName('payment_backend_url');
+ $form_state->setErrorByName('access_token',
+ $this->t('Access token not accepted by the merchant backend'));
+ return;
+ case 204:
+ // Empty order list is OK
+ break;
+ case 200:
+ // Success is great
+ break;
+ case 0:
+ $form_state->setErrorByName('payment_backend_url',
+ $this->t('HTTP request failed'));
+ $form_state->setErrorByName('access_token');
+ return;
+ default:
+ $form_state->setErrorByName('payment_backend_url',
+ $this->t('Unexpected response (' . $response->getStatusCode() . ') from merchant backend'));
+ $form_state->setErrorByName('access_token');
+ return;
+ } // end switch on HTTP status
// If the merchant backend is not configured at all, we allow the user
// to save the settings. But, we warn them if they did not set
@@ -371,14 +442,19 @@ class TurnstileSettingsForm extends ConfigFormBase {
}
$grant_access_on_error = $form_state->getValue('grant_access_on_error');
+
+if (FALSE) {
$subscription_prices = $form_state->getValue('subscription_prices');
+}
// Save configuration.
$config->set('enabled_content_types', $new_enabled_types);
$config->set('payment_backend_url', $payment_backend_url);
$config->set('access_token', $access_token);
$config->set('grant_access_on_error', $grant_access_on_error);
+if (FALSE) {
$config->set('subscription_prices', $subscription_prices);
+}
$config->save();
parent::submitForm($form, $form_state);
diff --git a/turnstile.links.menu.yml b/turnstile.links.menu.yml
@@ -1,13 +1,20 @@
turnstile.settings:
- title: 'Turnstile'
- description: 'Configure Turnstile settings'
+ title: 'Turnstile basics'
+ description: 'Configure GNU Taler payment backend and paid content types.'
parent: system.admin_config_system
route_name: turnstile.settings
+ weight: 98
+
+turnstile.subscription_prices:
+ title: 'Turnstile subscription prices'
+ description: 'Configure prices for Turnstile subscriptions.'
+ parent: system.admin_config_system
+ route_name: turnstile.subscription_prices
weight: 99
turnstile.turnstile_price_category.collection:
- title: 'Price categories'
+ title: 'Turnstile price categories'
route_name: entity.turnstile_price_category.collection
- description: 'Manage price categories for content.'
+ description: 'Manage price categories for Turnstile.'
parent: system.admin_structure
weight: 10
diff --git a/turnstile.module b/turnstile.module
@@ -81,6 +81,14 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent
return;
}
+ $subscriptions = $price_category->getFullSubscriptions();
+ foreach ($subscriptions as $subscription_id) {
+ if (_turnstile_is_subscriber ($subscription_id)) {
+ \Drupal::logger('turnstile')->debug('Subscriber detected, granting access.');
+ return;
+ }
+ }
+
// Disable page cache, this page is personalized!
\Drupal::service('page_cache_kill_switch')->trigger();
@@ -102,11 +110,18 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent
if ($order_status && $order_status['paid']) {
\Drupal::logger('turnstile')->debug('Order was paid, granting session access.');
_turnstile_grant_session_access($node_id);
+ if ($order_status['subscription_slug'] ?? FALSE) {
+ \Drupal::logger('turnstile')->debug('Subscription was purchased, granting subscription access.');
+ $subscription_slug = $order_status['subscription_slug'];
+ $expiration = $order_status['subscription_expiration'];
+ _turnstile_grant_subscriber_access ($subscription_slug, $expiration);
+ }
return;
}
if ($order_status &&
- ($order_status['order_expiration'] ?? 0) < time() + 30) {
- // If order expired, ignore it!
+ ($order_status['order_expiration'] ?? 0) < time() + 60) {
+ // If order expired (or would expire in less than one minute,
+ // so too soon for the user to still pay it), then ignore it!
$order_info = NULL;
}
if (!$order_status)
@@ -155,7 +170,7 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent
// Replace the build array with teaser content
// Keep important metadata from original build (?)
$build = [
- '#cache' => ['contexts' => ['url']],
+ '#cache' => ['contexts' => ['url']],
'#weight' => $build['#weight'] ?? 0,
];
@@ -178,6 +193,29 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent
/**
+ * Helper function to grant subscription access for this
+ * visitor to the given node ID until the given expiration time.
+ */
+function _turnstile_grant_subscriber_access($subscription_slug, $expiration) {
+ $session = \Drupal::request()->getSession();
+ $access_data = $session->get('turnstile_subscriptions', []);
+ $access_data[$subscription_slug] = $expiration;
+ $session->set('turnstile_subscriptions', $access_data);
+}
+
+
+/**
+ * Helper function to check if this session is currently
+ * subscribed on the given type of subscription.
+ */
+function _turnstile_is_subscriber($subscription_slug) {
+ $session = \Drupal::request()->getSession();
+ $access_data = $session->get('turnstile_subscriptions', []);
+ return ($access_data[$subscription_slug] ?? 0) < time();
+}
+
+
+/**
* Helper function to grant session access for this
* visitor to the given node ID.
*/
diff --git a/turnstile.routing.yml b/turnstile.routing.yml
@@ -2,7 +2,18 @@ turnstile.settings:
path: '/admin/config/system/Turnstile'
defaults:
_form: '\Drupal\turnstile\Form\TurnstileSettingsForm'
- _title: 'Turnstile Settings'
+ _title: 'Turnstile settings'
+ requirements:
+ _permission: 'administer Turnstile'
+ options:
+ _admin_route: TRUE
+
+# Route for editing subscription prices
+turnstile.subscription_prices:
+ path: '/admin/config/system/turnstile/subscription-prices'
+ defaults:
+ _form: '\Drupal\turnstile\Form\SubscriptionPricesForm'
+ _title: 'Subscription prices'
requirements:
_permission: 'administer Turnstile'
options:
@@ -13,7 +24,7 @@ entity.turnstile_price_category.collection:
path: '/admin/structure/price-categories'
defaults:
_entity_list: 'turnstile_price_category'
- _title: 'Price Categories'
+ _title: 'Price categories'
requirements:
_permission: 'administer price categories'
@@ -45,6 +56,6 @@ turnstile.debughook:
path: '/turndebug'
defaults:
_controller: '\Drupal\turnstile\Controller\DebugController::content'
- _title: 'Turnstile Debugger'
+ _title: 'Turnstile debugger'
requirements:
_permission: 'access content'
\ No newline at end of file