commit 9d6812f29325b17a6db45a51f7ad473d03f1ee07
parent 6279849d9330f686d9d1287e2255c6e190eb6490
Author: Christian Grothoff <christian@grothoff.org>
Date: Sat, 11 Oct 2025 18:56:06 +0200
DCE
Diffstat:
8 files changed, 28 insertions(+), 408 deletions(-)
diff --git a/README.md b/README.md
@@ -46,8 +46,14 @@ Navigate to `/admin/config/content/Turnstile` to configure:
## TODO
+- paywall rendering broke again, debug!
- actually *use* price categories when determining article price!
- => keep or remove price field?
+ => make v1 multi-currency orders work
+- actually get subscriptions to work: v1 contracts, etc.
+ note that the logic is now more funky, customers could have a 50%-off
+ subscription. So need to check if we have a subscription cookie
+ set for a subscription that for the current category would give us
+ a price of zero!
- Make truncation logic work with tiles / cards
diff --git a/src/Entity/TurnstilePriceCategory.php b/src/Entity/TurnstilePriceCategory.php
@@ -96,6 +96,7 @@ class TurnstilePriceCategory extends ConfigEntityBase {
/**
* Gets the price for a specific subscription and currency.
+ * FIXME: just an example for now, to be removed!
*
* @param string $subscription_id
* The subscription ID.
diff --git a/src/Form/TurnstileSettingsForm.php b/src/Form/TurnstileSettingsForm.php
@@ -320,7 +320,7 @@ class TurnstileSettingsForm extends ConfigFormBase {
}
if (!empty($added_labels)) {
$this->messenger()->addStatus(
- $this->t('Price and price category fields added to: @types', [
+ $this->t('Price category fields added to: @types', [
'@types' => implode(', ', $added_labels),
])
);
@@ -336,7 +336,7 @@ class TurnstileSettingsForm extends ConfigFormBase {
}
if (!empty($removed_labels)) {
$this->messenger()->addStatus(
- $this->t('Price and price category fields removed from: @types', [
+ $this->t('Price category fields removed from: @types', [
'@types' => implode(', ', $removed_labels),
])
);
diff --git a/src/Plugin/Validation/Constraint/TalerPriceListFormatConstraint.php b/src/Plugin/Validation/Constraint/TalerPriceListFormatConstraint.php
@@ -1,26 +0,0 @@
-<?php
-
-namespace Drupal\turnstile\Plugin\Validation\Constraint;
-
-use Symfony\Component\Validator\Constraint;
-
-/**
- * Checks that the price format is valid.
- *
- * @Constraint(
- * id = "TalerPriceListFormat",
- * label = @Translation("Taler Price List Format", context = "Validation"),
- * type = "string",
- * )
- */
-class TalerPriceListFormatConstraint extends Constraint {
-
- public $invalidFormat = 'The price list "@value" is not valid. Please enter a valid price list (e.g., "EUR:1" or "USD:3,CHF:2" to support payment in multiple currencies).';
-
- public $invalidCurrency = 'Price uses currency "@currency" that is not supported by the Taler merchant backend. The supported currencies are: @supported';
-
- public $backendTrouble = 'Failed to fetch configuration data from Taler merchant backend.';
-
- public $internalError = 'Failed to validate price list: @error';
-
-}
-\ No newline at end of file
diff --git a/src/Plugin/Validation/Constraint/TalerPriceListFormatConstraintValidator.php b/src/Plugin/Validation/Constraint/TalerPriceListFormatConstraintValidator.php
@@ -1,144 +0,0 @@
-<?php
-
-namespace Drupal\turnstile\Plugin\Validation\Constraint;
-
-use Symfony\Component\Validator\Constraint;
-use Symfony\Component\Validator\ConstraintValidator;
-
-/**
- * Validates the TalerPriceListFormat constraint.
- */
-class TalerPriceListFormatConstraintValidator extends ConstraintValidator {
-
- /**
- * {@inheritdoc}
- */
- public function validate($item, Constraint $constraint) {
- \Drupal::logger('turnstile')->debug('Validating price list');
-
- if (!isset($item)) {
- \Drupal::logger('turnstile')->debug('No item, skipping validation');
- return;
- }
-
- $pricelist = $item->value;
-
- // Empty is OK, simply gratis.
- if (empty($pricelist)) {
- \Drupal::logger('turnstile')->debug('No pricelist, skipping validation');
- return;
- }
-
-
- $pattern = '/^(([A-Za-z]{1,11}:\d*(\.\d{1,8})?)(\s*,\s*[A-Za-z]{1,11}:\d*(\.\d{1,8})?)*)?$/';
-
- if (preg_match($pattern, $pricelist) !== 1) {
- \Drupal::logger('turnstile')->debug('Pricelist has invalid format, rejecting it');
- $this->context->addViolation($constraint->invalidFormat, [
- '@value' => $pricelist,
- ]);
- return;
- }
-
- $config = \Drupal::config('turnstile.settings');
- $payment_backend_url = $config->get('payment_backend_url');
- $grant_access_on_error = $config->get('grant_access_on_error');
-
- if (empty($payment_backend_url)) {
- // If backend is not configured, allow the pricelist if
- // grant_access_on_error is set.
- \Drupal::logger('turnstile')->error('Taler merchant backend not configured; cannot validate pricelist');
- if ($grant_access_on_error) {
- return;
- }
- $this->context->addViolation($constraint->backendTrouble);
- return;
- }
-
- try {
- // Fetch backend configuration.
- $client = \Drupal::httpClient();
-
- $config_url = $payment_backend_url . 'config';
- $response = $client->get($config_url, [
- 'allow_redirects' => TRUE,
- 'http_errors' => FALSE,
- 'timeout' => 5,
- ]);
-
- if ($response->getStatusCode() !== 200) {
- // If backend is unavailable, allow the pricelist if
- // grant_access_on_error is set.
- \Drupal::logger('turnstile')->error('Taler merchant backend unavailable, cannot validate pricelist');
- $this->backendTrouble = TRUE;
- if ($grant_access_on_error) {
- return;
- }
- $this->context->addViolation($constraint->backendTrouble);
- return;
- }
-
- $backend_config = json_decode($response->getBody(), TRUE);
- if (!$backend_config || !is_array($backend_config)) {
- // Invalid response, fallback to grant_access_on_error setting.
- \Drupal::logger('turnstile')->error('Taler merchant backend returned invalid /config response; cannot validate pricelist');
- $this->backendTrouble = TRUE;
- if ($grant_access_on_error) {
- return;
- }
- $this->context->addViolation($constraint->backendTrouble);
- return;
- }
-
- if (! isset($backend_config['currencies']))
- {
- \Drupal::logger('turnstile')->error('Backend returned malformed response for /config');
- $this->context->addViolation($constraint->backendTrouble);
- return;
- }
-
- // Parse and validate each amount in the comma-separated list.
- $amounts = preg_split('/\s*,\s*/', trim($pricelist));
- $currencies = $backend_config['currencies'];
-
- foreach ($amounts as $amount) {
- // Extract currency from each amount (format: CURRENCY:NUMBER[.FRACTION]).
- \Drupal::logger('turnstile')->debug('Checking amount @amount', [
- '@amount' => $amount,
- ]);
- if (preg_match('/^([A-Za-z]{1,11}):/', $amount, $matches)) {
- $currency = $matches[1];
- \Drupal::logger('turnstile')->debug('Checking currency @currency', [
- '@currency' => $currency,
- ]);
-
- if (! isset($currencies[strtoupper($currency)])) {
- \Drupal::logger('turnstile')->debug('Unsupported currency in pricelist, rejecting it');
- $this->context->addViolation($constraint->invalidCurrency, [
- '@currency' => $currency,
- '@supported' => implode(', ', array_keys($currencies)),
- ]);
- return;
- }
- }
- }
- \Drupal::logger('turnstile')->debug('Pricelist is OK');
- return;
-
- } catch (\Exception $e) {
-
- // On exception, fall back to grant_access_on_error setting.
- \Drupal::logger('turnstile')->error('Failed to validate pricelist with backend: @error', [
- '@error' => $e->getMessage(),
- ]);
- if ($grant_access_on_error) {
- return;
- }
- $this->context->addViolation($constraint->invalidCurrency, [
- '@error' => $e->getMessage(),
- ]);
- return;
- }
- }
-
-}
-\ No newline at end of file
diff --git a/src/TalerMerchantApiService.php b/src/TalerMerchantApiService.php
@@ -10,7 +10,9 @@
namespace Drupal\turnstile;
use Drupal\Core\Http\ClientFactory;
+use Drupal\node\NodeInterface;
use Psr\Log\LoggerInterface;
+use Drupal\turnstile\Entity\TurnstilePriceCategory;
@@ -254,21 +256,21 @@ class TalerMerchantApiService {
case 404:
// Order unknown or instance unknown
/** @var TalerErrorCode $ec */
- $ec = $result['code'] ?? TALER_EC_NONE;
+ $ec = TalerErrorCode::tryFrom ($result['code']) ?? TalerErrorCode::TALER_EC_NONE;
switch ($ec)
{
- case TALER_EC_NONE:
+ case TalerErrorCode::TALER_EC_NONE:
// Protocol violation. Could happen if the backend domain was
// taken over by someone else.
$body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$this->logger->error('Invalid response from merchant backend when trying to obtain order status. Check your Turnstile configuration! @body', ['@body' => $body_log ?? 'N/A']);
return FALSE;
- case TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN:
+ case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN:
// This could happen if our instance was deleted after the configuration was
// checked. Very bad, log serious error.
$this->logger->error('Configured instance "@detail" unknown to merchant backend. Check your Turnstile configuration!', ['@detail' => $result['detail'] ?? 'N/A']);
return FALSE;
- case TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN:
+ case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN:
// This could happen if the instance owner manually deleted
// an order while the customer was looking at the article.
$this->logger->warning('Order "@order" disappeared in the backend.', ['@order' => $order_id]);
@@ -360,13 +362,16 @@ class TalerMerchantApiService {
return FALSE;
}
- // FIXME: transition away from price to price categories...
- $price = $node->get('field_price')->value;
- if (empty($price)) {
- $this->logger->debug('No price, cannot setup new order');
+ /** @var TurnstilePriceCategory $price_category */
+ $price_category = $node->get('field_turnstile_price_category');
+ if (! $price_category) {
+ $this->logger->debug('No price category, cannot setup new order');
return FALSE;
}
+ // FIXME: map price category to price(s)!
+ $price = "KUDOS:1";
+
// FIXME: support v1 contract terms and use it
// if we have multiple currencies in $price!
// FIXME: add support for subscriptions
diff --git a/src/TurnstileFieldManager.php b/src/TurnstileFieldManager.php
@@ -49,7 +49,6 @@ class TurnstileFieldManager {
continue;
}
- $this->addPriceField($bundle);
$this->addPriceCategoryField($bundle);
}
}
@@ -58,23 +57,6 @@ class TurnstileFieldManager {
* Ensure field storage configurations exist.
*/
protected function ensureFieldStorageExists() {
- // Create price field storage if it doesn't exist.
- $field_price_storage = FieldStorageConfig::loadByName('node', 'field_price');
- if (!$field_price_storage) {
- $field_price_storage = FieldStorageConfig::create([
- 'field_name' => 'field_price',
- 'entity_type' => 'node',
- 'type' => 'string',
- 'cardinality' => 1,
- 'settings' => [
- 'max_length' => 255,
- 'case_sensitive' => FALSE,
- 'is_ascii' => TRUE,
- ],
- ]);
- $field_price_storage->save();
- }
-
// Create price category field storage if it doesn't exist.
$field_category_storage = FieldStorageConfig::loadByName('node', 'field_turnstile_price_category');
if (!$field_category_storage) {
@@ -92,64 +74,6 @@ class TurnstileFieldManager {
}
/**
- * Add price field to a bundle.
- *
- * @param string $bundle
- * The bundle machine name.
- */
- protected function addPriceField($bundle) {
- $field_price_storage = FieldStorageConfig::loadByName('node', 'field_price');
-
- $existing_field = FieldConfig::loadByName('node', $bundle, 'field_price');
- if ($existing_field) {
- // Ensure constraint is applied.
- $constraints = $existing_field->getConstraints();
- if (!isset($constraints['TalerPriceListFormat'])) {
- $constraints['TalerPriceListFormat'] = [];
- $existing_field->set('constraints', $constraints);
- $existing_field->save();
- }
- }
- else {
- // Create field configuration.
- $field_config = FieldConfig::create([
- 'field_storage' => $field_price_storage,
- 'bundle' => $bundle,
- 'label' => 'Price',
- 'description' => 'Price for accessing this content (e.g., "EUR:5" or "USD:5,CHF:3.50" to support payment in multiple currencies)',
- 'required' => FALSE,
- ]);
- $field_config->setConstraints(['TalerPriceListFormat' => []]);
- $field_config->save();
-
- // Add to form display.
- $form_display = EntityFormDisplay::load('node.' . $bundle . '.default');
- if ($form_display) {
- $form_display->setComponent('field_price', [
- 'type' => 'string_textfield',
- 'weight' => 10,
- 'settings' => [
- 'size' => 60,
- 'placeholder' => 'e.g., EUR:1 or USD:5,CHF:3.50',
- ],
- ]);
- $form_display->save();
- }
-
- // Add to view display.
- $view_display = EntityViewDisplay::load('node.' . $bundle . '.default');
- if ($view_display) {
- $view_display->setComponent('field_price', [
- 'type' => 'string',
- 'weight' => 10,
- 'label' => 'above',
- ]);
- $view_display->save();
- }
- }
- }
-
- /**
* Add price category field to a bundle.
*
* @param string $bundle
@@ -218,11 +142,6 @@ class TurnstileFieldManager {
*/
public function removeFieldsFromContentTypes(array $bundles) {
foreach ($bundles as $bundle) {
- $field_config = FieldConfig::loadByName('node', $bundle, 'field_price');
- if ($field_config) {
- $field_config->delete();
- }
-
$field_config = FieldConfig::loadByName('node', $bundle, 'field_turnstile_price_category');
if ($field_config) {
$field_config->delete();
@@ -231,7 +150,6 @@ class TurnstileFieldManager {
// Remove from form display.
$form_display = EntityFormDisplay::load('node.' . $bundle . '.default');
if ($form_display) {
- $form_display->removeComponent('field_price');
$form_display->removeComponent('field_turnstile_price_category');
$form_display->save();
}
@@ -239,7 +157,6 @@ class TurnstileFieldManager {
// Remove from view display.
$view_display = EntityViewDisplay::load('node.' . $bundle . '.default');
if ($view_display) {
- $view_display->removeComponent('field_price');
$view_display->removeComponent('field_turnstile_price_category');
$view_display->save();
}
@@ -252,19 +169,6 @@ class TurnstileFieldManager {
* Clean up field storage if no content types are using it.
*/
protected function cleanupFieldStorage() {
- $field_price_storage = FieldStorageConfig::loadByName('node', 'field_price');
- if ($field_price_storage) {
- $field_configs = $this->entityTypeManager
- ->getStorage('field_config')
- ->loadByProperties([
- 'field_storage' => $field_price_storage,
- ]);
-
- if (empty($field_configs)) {
- $field_price_storage->delete();
- }
- }
-
$field_category_storage = FieldStorageConfig::loadByName('node', 'field_turnstile_price_category');
if ($field_category_storage) {
$field_configs = $this->entityTypeManager
diff --git a/turnstile.module b/turnstile.module
@@ -13,46 +13,8 @@ use GuzzleHttp\Exception\RequestException;
/**
- * Implements hook_entity_bundle_field_info_alter().
- * Adds the TalerPriceListFormat constraint to 'field_price' fields.
- * Probably can be removed entirely once we have only price categories.
- */
-function turnstile_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) {
- $log_verbose = FALSE;
- if ($entity_type->id() !== 'node') {
- if ($log_verbose) {
- \Drupal::logger('turnstile')->debug('Node ID "@id" is not one Turnstile cares about', [
- '@id' => $entity_type->id(),
- ]);
- }
- return;
- }
- $config = \Drupal::config('turnstile.settings');
- $enabled_types = $config->get('enabled_content_types') ?: [];
-
- // Only show price field on enabled content types.
- if (! in_array($bundle, $enabled_types)) {
- if ($log_verbose) {
- \Drupal::logger('turnstile')->debug('Content type "@bundle" is not one Turnstile cares about', [
- '@bundle' => $bundle,
- ]);
- }
- return;
- }
-
- if (isset($fields['field_price'])) {
- // Use the ID as defined in the annotation of the constraint definition
- $fields['field_price']->addConstraint('TalerPriceListFormat', []);
- } else {
- \Drupal::logger('turnstile')->debug('Node lacks field_price, strange. Not adding constraint.');
- }
-}
-
-
-/**
* Implements hook_form_FORM_ID_alter() for node forms. Adds a
- * description for the Turnstile fields.
- * FIXME: is there a better way to do this?
+ * description for the Turnstile price category field.
*/
function turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
$node = $form_state->getFormObject()->getEntity();
@@ -61,10 +23,6 @@ function turnstile_form_node_form_alter(&$form, \Drupal\Core\Form\FormStateInter
// Only show price field on enabled content types.
if (in_array($node->bundle(), $enabled_types)) {
- if (isset($form['field_price'])) {
- $form['field_price']['#group'] = 'meta';
- $form['field_price']['widget'][0]['value']['#description'] = t('Set a price to enable paywall protection for this content.');
- }
if (isset($form['field_turnstile_price_category'])) {
$form['field_turnstile_price_category']['#group'] = 'meta';
$form['field_turnstile_price_category']['widget'][0]['value']['#description'] = t('Set a price category to enable paywall protection for this content.');
@@ -106,8 +64,9 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent
$node = $entity;
// Check if the node has a price field and it's not empty.
- if (!$node->hasField('field_price') || $node->get('field_price')->isEmpty()) {
- \Drupal::logger('turnstile')->debug('Node has no price set, skipping Turnstile checks.');
+ if (!$node->hasField('field_turnstile_price_category') ||
+ $node->get('field_turnstile_price_category')->isEmpty()) {
+ \Drupal::logger('turnstile')->debug('Node has no price category set, skipping Turnstile checks.');
return;
}
@@ -117,7 +76,7 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent
// Check if this node type is enabled for paywall.
if (!in_array($node->bundle(), $enabled_types)) {
- // This case is very strange: how can we have a field_price if turnstile is not enabled?
+ // This case is very strange: how can we have a field_turnstile_price_category if turnstile is not enabled?
\Drupal::logger('turnstile')->error('Bundle has price but is not enabled with Turnstile, skipping payment.');
return;
}
@@ -349,85 +308,3 @@ function turnstile_theme() {
],
];
}
-
-
-
-/**
- * Debugs a price category by outputting all its settings.
- *
- * @param string $price_category_id
- * The price category identifier.
- *
- * @return string
- * Formatted output of the price category settings.
- */
-function debug_price_category($price_category_id) {
- $output = [];
-
- // Load the price category.
- $price_category = \Drupal::entityTypeManager()
- ->getStorage('turnstile_price_category')
- ->load($price_category_id);
-
- if (!$price_category) {
- return "Price category '$price_category_id' not found.\n";
- }
-
- // Get API service for subscription and currency labels.
- $api_service = \Drupal::service('turnstile.api_service');
- $subscriptions = $api_service->getSubscriptions();
- $currencies = $api_service->getCurrencies();
-
- // Create lookup arrays for labels.
- $subscription_labels = [];
- foreach ($subscriptions as $sub) {
- $id = $sub['id'] ?? $sub['name'];
- $subscription_labels[$id] = $sub['label'] ?? $sub['name'];
- }
-
- $currency_labels = [];
- foreach ($currencies as $curr) {
- $code = $curr['code'] ?? $curr['name'];
- $currency_labels[$code] = $curr['label'] ?? $curr['code'];
- }
-
- // Build output.
- $output[] = "=== Price Category: {$price_category->label()} ===";
- $output[] = "ID: {$price_category->id()}";
- $output[] = "Description: {$price_category->getDescription()}";
- $output[] = "";
- $output[] = "Prices:";
- $output[] = str_repeat('-', 60);
-
- $prices = $price_category->getPrices();
-
- if (empty($prices)) {
- $output[] = " No prices configured.";
- }
- else {
- foreach ($prices as $subscription_id => $currency_prices) {
- $sub_label = $subscription_labels[$subscription_id] ?? $subscription_id;
- $output[] = "";
- $output[] = " Subscription: $sub_label";
-
- if (empty($currency_prices)) {
- $output[] = " No prices set for this subscription.";
- }
- else {
- foreach ($currency_prices as $currency_code => $price) {
- $curr_label = $currency_labels[$currency_code] ?? $currency_code;
- $output[] = " $curr_label ($currency_code): $price";
- }
- }
- }
- }
-
- $output[] = str_repeat('=', 60);
-
- return implode("\n", $output) . "\n";
-}
-
-/**
- * Usage example for drush:
- * drush php-eval "echo debug_price_category('premium');"
- */
-\ No newline at end of file