commit a54d890e6fe1e2338a7b7823fb1f928699130618
parent 33f75c04c03b343655f3b695acdae770fea4ff47
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 9 Oct 2025 12:30:59 +0200
first kind-of working version
Diffstat:
| M | turnstile.module | | | 400 | ++++++++++++++++++++++++++++++++++++++++++++----------------------------------- |
1 file changed, 223 insertions(+), 177 deletions(-)
diff --git a/turnstile.module b/turnstile.module
@@ -17,10 +17,13 @@ use GuzzleHttp\Exception\RequestException;
* Implements hook_entity_bundle_field_info_alter().
*/
function turnstile_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) {
+ $log_verbose = FALSE;
if ($entity_type->id() !== 'node') {
- \Drupal::logger('turnstile')->debug('Node ID "@id" is not one Turnstile cares about', [
- '@id' => $entity_type->id(),
- ]);
+ 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');
@@ -28,9 +31,11 @@ function turnstile_entity_bundle_field_info_alter(&$fields, EntityTypeInterface
// Only show price field on enabled content types.
if (! in_array($bundle, $enabled_types)) {
- \Drupal::logger('turnstile')->debug('Content type "@bundle" is not one Turnstile cares about', [
- '@bundle' => $bundle,
- ]);
+ if ($log_verbose) {
+ \Drupal::logger('turnstile')->debug('Content type "@bundle" is not one Turnstile cares about', [
+ '@bundle' => $bundle,
+ ]);
+ }
return;
}
@@ -48,9 +53,8 @@ function turnstile_entity_bundle_field_info_alter(&$fields, EntityTypeInterface
* Implements hook_entity_view_alter().
*/
function turnstile_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
- // Only process nodes.
if (!$entity instanceof NodeInterface) {
- \Drupal::logger('turnstile')->debug('Not a node, skipping Turnstile checks.');
+ // Turnstile only processes nodes.
return;
}
/** @var \Drupal\node\NodeInterface $node */
@@ -58,7 +62,7 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent
// 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, skipping Turnstile checks.');
+ \Drupal::logger('turnstile')->debug('Node has no price set, skipping Turnstile checks.');
return;
}
@@ -68,9 +72,8 @@ 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)) {
- // NOTE: This case is very strange: how can we have a field_price if
- // turnstile is not enabled?
- \Drupal::logger('turnstile')->debug('Bundle not enabled with Turnstile, skipping payment.');
+ // This case is very strange: how can we have a field_price if turnstile is not enabled?
+ \Drupal::logger('turnstile')->error('Bundle has price but is not enabled with Turnstile, skipping payment.');
return;
}
@@ -91,16 +94,8 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent
}
\Drupal::logger('turnstile')->debug('Checking for payment...');
- // Check if user is a subscriber.
- // NOTE: setting subscriber status is not yet supported!
- if (isset($_SESSION['turnstile_subscriber']) &&
- $_SESSION['turnstile_subscriber'] === TRUE) {
- // FIXME: should set turnstile_subscriber to *expiration* time
- // of the subscription and check not for TRUE but for "greater
- // current time".
- // FIXME: For now, subscribers have full access to everything
- // in the future, we may want to have 50%-off subscriptions
- // and others where we need to check the exact type of subscription.
+ // Check if user has already paid for a non-expired subscription.
+ if (time() < $_SESSION['turnstile_subscriber'] ?? 0) {
\Drupal::logger('turnstile')->debug('Subscriber cookie detected, granting access.');
return;
}
@@ -116,36 +111,30 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent
if (isset($_SESSION['turnstile_orders'][$node_id])) {
$order = $_SESSION['turnstile_orders'][$node_id];
- // Check if order is paid and not refunded.
- // FIXME: refunds are not really supported, do we care to
- // keep support for them here?
- if (isset($order['paid']) &&
- (TRUE === $order['paid']) &&
- ( (! isset($order['refunded'])) ||
- (FALSE === $order['refunded'])) ) {
+ // Check if order is paid.
+ if ($order['paid'] ?? FALSE) {
\Drupal::logger('turnstile')->debug('Paid order detected, passing Turnstile.');
return;
}
// Check order status with backend if not marked as paid.
- if (!$order['paid']) {
- \Drupal::logger('turnstile')->debug('Checking order status ...');
- $status = turnstile_check_order_status($order['order_id']);
- if ($status && $status['paid']) {
- // Update session with new status.
- // FIXME: if we keep refunds above, we should check
- // for refunds here as well!
- \Drupal::logger('turnstile')->debug('Updating order status to paid.');
- $_SESSION['turnstile_orders'][$node_id]['paid'] = TRUE;
- return;
+ \Drupal::logger('turnstile')->debug('Checking order status ...');
+ $status = turnstile_check_order_status($order['order_id']);
+ if ($status && $status['paid']) {
+ // Update session with new status.
+ \Drupal::logger('turnstile')->debug('Updating order status to paid.');
+ $_SESSION['turnstile_orders'][$node_id]['paid'] = TRUE;
+ if ($status['subscription_expiration'] > time()) {
+ \Drupal::logger('turnstile')->debug('Paid subscription detected, enabling subscription mode.');
+ $_SESSION['turnstile_subscriber'] = $status['subscription_expiration'];
}
+ return;
}
- }
+ } // end case where we had an order stored in the session
// No valid payment found, need to create order or show payment option.
$order_info = turnstile_create_or_get_order($node);
if (! $order_info) {
- \Drupal::logger('turnstile')->debug('Failed to create order.');
// Fallback: just return body, not good to have unhappy readers.
$config = \Drupal::config('turnstile.settings');
$grant_access_on_error = $config->get('grant_access_on_error') ?: true;
@@ -159,12 +148,18 @@ function turnstile_entity_view_alter(array &$build, EntityInterface $entity, Ent
\Drupal::logger('turnstile')->debug('Rendering page with payment request.');
- _turnstile_transform_body_recursive ($build, $node, $order_info);
+ // Disable page cache, this page is going to be personalized.
+ \Drupal::service('page_cache_kill_switch')->trigger();
- // FIXME: we probably need to invalidate caches using
- // something like this (untested!)
- // $build['body']['#cache']['contexts'][] = 'user.permissions';
+ _turnstile_transform_body_recursive ($build, $node, $order_info);
+ // We could now set the HTTP header to trigger the Taler-wallet
+ // immediately. However, this will prevent the user from seeing
+ // the preview (other than in the summary text of the order).
+ // FIXME: enable/disable setting the header based on a site-wide
+ // policy option!
+ // Set the Taler:-HTTP header with the response to the client!
+ header('XX-Taler: xtaler://pay/FIXME'); // For now, use broken header...
}
@@ -197,14 +192,13 @@ function _turnstile_transform_body_recursive(array &$build, NodeInterface $node,
$key === 'body'
) {
if ($node->hasField('body') && !$node->get('body')->isEmpty()) {
+ \Drupal::logger('turnstile')->debug('Generating payment request body');
$body_field = $node->get('body')->first();
$summary = $body_field->summary;
$full = $body_field->value;
$format = $body_field->format;
- // FIXME: use text_summary, or
- // maybe better turnstile_truncate_content_safely? Not sure!
$replacement_text = $summary ?: text_summary($full, $format, 300);
// Replace with processed summary or trimmed text.
@@ -226,6 +220,28 @@ function _turnstile_transform_body_recursive(array &$build, NodeInterface $node,
/**
+ * Render payment button.
+ *
+ * @param array $order_info
+ * Order information.
+ *
+ * @return string
+ * Rendered payment button.
+ */
+function turnstile_render_payment_button($order_info) {
+ // FIXME: We should modify this to
+ // inline the payment dialog / QR code and long-poll
+ // on the order status on this page!
+
+ $payment_button = '<div class="paywall-payment-section" style="margin-top: 20px; padding: 20px; border: 1px solid #ddd; background: #f9f9f9;">';
+ $payment_button .= '<p><strong>Please pay with GNU Taler to continue reading.</strong></p>';
+ $payment_button .= '<a href="' . htmlspecialchars($order_info['payment_url']) . '" class="button btn btn-primary" style="display: inline-block; padding: 10px 20px; background: #007cba; color: white; text-decoration: none; border-radius: 4px;">Click here to open payment dialog</a>';
+ $payment_button .= '</div>';
+ return $payment_button;
+}
+
+
+/**
* Create or get existing order for a node.
*
* @param \Drupal\node\NodeInterface $node
@@ -239,106 +255,24 @@ function turnstile_create_or_get_order(NodeInterface $node) {
// Check if we already have an order for this node in session
if (isset($_SESSION['turnstile_orders'][$node_id])) {
- // FIXME: check of order expired, not useful if payment deadline expired!
- \Drupal::logger('turnstile')->debug('Re-using existing order.');
- return $_SESSION['turnstile_orders'][$node_id];
+ $order_info = $_SESSION['turnstile_orders'][$node_id];
+ $order_expiration = $order_info['order_expiration'] ?? 0;
+ if ($order_expiration > time()) {
+ \Drupal::logger('turnstile')->debug('Re-using existing order.');
+ return $_SESSION['turnstile_orders'][$node_id];
+ }
+ \Drupal::logger('turnstile')->debug('Existing order payment deadline expired, creating a new one.');
}
// Create new order
$order_info = turnstile_create_order($node);
- if ($order_info) {
- // Store order in session
- $_SESSION['turnstile_orders'][$node_id] = $order_info;
- \Drupal::logger('turnstile')->debug('Created new order.');
- return $order_info;
- }
- \Drupal::logger('turnstile')->debug('Failed to create order.');
- return FALSE;
-}
-
-
-/**
- * Safely truncate HTML content.
- *
- * @param string $content
- * The HTML content to truncate.
- * @param int $length
- * Maximum length in characters.
- *
- * @return string
- * Safely truncated HTML.
- */
-function turnstile_truncate_content_safely($content, $length = 200) {
- // Remove HTML tags for length calculation.
- $text_only = strip_tags($content);
-
- if (strlen($text_only) <= $length) {
- \Drupal::logger('turnstile')->debug('Body too short, skipping payment transformation.');
- return $content;
- }
-
- // Use DOMDocument to safely truncate HTML.
- $dom = new \DOMDocument();
- libxml_use_internal_errors(true);
- $dom->loadHTML('<?xml encoding="UTF-8">' . $content, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
- libxml_clear_errors();
-
- $truncated = '';
- $current_length = 0;
-
- foreach ($dom->childNodes as $node) {
- if ($current_length >= $length) {
- break;
- }
-
- $node_text = $node->textContent;
- if ($current_length + strlen($node_text) > $length) {
- // Truncate this node.
- $remaining = $length - $current_length;
- $truncated_text = substr($node_text, 0, $remaining) . '...';
-
- if ($node->nodeType === XML_TEXT_NODE) {
- $truncated .= htmlspecialchars($truncated_text);
- } else {
- // For element nodes, create a copy with truncated text.
- $temp_node = $node->cloneNode(FALSE);
- $temp_node->textContent = $truncated_text;
- $truncated .= $dom->saveHTML($temp_node);
- }
- break;
- } else {
- $truncated .= $dom->saveHTML($node);
- $current_length += strlen($node_text);
- }
+ if (! $order_info) {
+ return FALSE;
}
- \Drupal::logger('turnstile')->debug('Returning truncated body.');
-
- return $truncated;
-}
-
-
-/**
- * Render payment button.
- *
- * @param array $order_info
- * Order information.
- *
- * @return string
- * Rendered payment button.
- */
-function turnstile_render_payment_button($order_info) {
- \Drupal::logger('turnstile')->debug('Generating payment request body');
-
- // FIXME: eventually, we may want to at least give the option to
- // inline the payment dialog / QR code and long-poll ourselves,
- // and/or to send the Taler:-HTTP header with the response to the client!
- // Add payment button.
- $payment_button = '<div class="paywall-payment-section" style="margin-top: 20px; padding: 20px; border: 1px solid #ddd; background: #f9f9f9;">';
- $payment_button .= '<p><strong>Please pay with GNU Taler to continue reading.</strong></p>';
- $payment_button .= '<a href="' . htmlspecialchars($order_info['payment_url']) . '" class="button btn btn-primary" style="display: inline-block; padding: 10px 20px; background: #007cba; color: white; text-decoration: none; border-radius: 4px;">Click here to open payment dialog</a>';
- $payment_button .= '</div>';
-
- return $payment_button;
+ // Store order in session
+ $_SESSION['turnstile_orders'][$node_id] = $order_info;
+ \Drupal::logger('turnstile')->debug('Created new order.');
+ return $order_info;
}
@@ -374,8 +308,9 @@ function turnstile_check_order_status($order_id) {
$backend_url = $config->get('payment_backend_url');
$access_token = $config->get('access_token');
- if (empty($backend_url) || empty($access_token)) {
- \Drupal::logger('turnstile')->debug('No backend, cannot check order status');
+ if (empty($backend_url) ||
+ empty($access_token)) {
+ \Drupal::logger('turnstile')->debug('No Turnstile backend configured, cannot check order status!');
return FALSE;
}
@@ -385,30 +320,102 @@ function turnstile_check_order_status($order_id) {
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
],
+ // Do not throw exceptions on 4xx/5xx status codes
+ 'http_errors' => false,
]);
- // Check HTTP status
- if ($response->getStatusCode() !== 200) {
- // FIXME: treat expired/deleted orders differently!
- \Drupal::logger('turnstile')->debug('Failed to obtain order status');
- return FALSE;
+ $http_status = $response->getStatusCode();
+ $body = $response->getBody();
+ $result = json_decode($body, TRUE);
+ switch ($http_status)
+ {
+ case 200:
+ // Success, handled below
+ break;
+ // FIXME: also handle unauthorized!
+ case 404:
+ // Order unknown or instance unknown
+ $ec = $result['code'] ?? 0;
+ switch ($ec)
+ {
+ case 0:
+ // 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);
+ \Drupal::logger('turnstile')->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 2000: // FIXME: MERCHANT_GENERIC_INSTANCE_UNKNOWN
+ // This could happen if our instance was deleted after the configuration was
+ // checked. Very bad, log serious error.
+ \Drupal::logger('turnstile')->error('Configured instance "@detail" unknown to merchant backend. Check your Turnstile configuration!', ['@detail' => $result['detail'] ?? 'N/A']);
+ return FALSE;
+ case 2005: // FIXME: MERCHANT_GENERIC_ORDER_UNKNOWN
+ // This could happen if the instance owner manually deleted
+ // an order while the customer was looking at the article.
+ \Drupal::logger('turnstile')->warning('Order "@order" disappeared in the backend.', ['@order' => $order_id]);
+ return FALSE;
+ default:
+ $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ \Drupal::logger('turnstile')->error('Unexpected error code @ec with HTTP status code @status from Taler merchant backend when trying to get order status: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']);
+ return FALSE;
+ }
+ default:
+ // Internal server errors and the like...
+ $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ \Drupal::logger('turnstile')->error('Unexpected HTTP status code @status from Taler merchant backend when trying to get order status: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']);
+ return FALSE;
}
- $result = json_decode($response->getBody(), TRUE);
-
- // FIXME: check $result['contract_terms'] to see if this is
- // a valid *subscription*, and if so, return subscription status!
-
+ $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ \Drupal::logger('turnstile')->error('Got existing contract: @body', ['@body' => $body_log ?? 'N/A']);
+
+ $order_status = $result['order_status'] ?? 'unknown';
+ $subscription_expiration = 0;
+ $pay_deadline = 0;
+ $paid = FALSE;
+ switch ($order_status)
+ {
+ case 'unpaid':
+ // 'pay_deadline' is only available since v21 rev 1, so for now we
+ // fall back to creation_time + offset.
+ $pay_deadline = $result['pay_deadline']['t_s'] ??
+ 60 * 60 * 24 + $result['creation_time']['t_s'] ?? 0;
+ break;
+ case 'claimed':
+ $contract_terms = $result['contract_terms'];
+ $pay_deadline = $contract_terms['pay_deadline']['t_s'] ?? 0;
+ break;
+ case 'paid':
+ $paid = TRUE;
+ $contract_terms = $result['contract_terms'];
+ $contract_version = $result['version'] ?? 0;
+ switch ($contract_version) {
+ case 0:
+ break;
+ case 1:
+ $choice_index = $result['choice_index'] ?? 0;
+ $contract_choice = $contract_terms['choices'][$choice_index];
+ $outputs = $contract_choice['outputs'];
+ // FIXME: add logic to detect subscriptions here and
+ // update $subscription_expiration if one was found!
+ break;
+ default:
+ break;
+ } // switch on contract version
+ break;
+ default:
+ \Drupal::logger('turnstile')->error('Got unexpected order status "@status"', ['@status' => $order_status]);
+ break;
+ } // switch on $order_status
return [
'order_id' => $order_id,
- 'paid' => isset($result['order_status']) && $result['order_status'] === 'paid',
- 'refunded' => isset($result['refunded']) && $result['refunded'] === TRUE,
- 'order_status' => $result['order_status'] ?? 'unknown',
- // FIXME: initialize 'created'?
- // FIXME: probably better keep when offer expires!
+ 'paid' => $paid,
+ 'subscription_expiration' => $subscription_expiration,
+ 'order_expiration' => $pay_deadline,
];
}
catch (RequestException $e) {
+ // Any kind of error that is outside of the spec.
\Drupal::logger('turnstile')->error('Failed to check order status: @message', ['@message' => $e->getMessage()]);
return FALSE;
}
@@ -434,19 +441,30 @@ function turnstile_create_order(NodeInterface $node) {
return FALSE;
}
+ // FIXME: transition away from price to price categories...
$price = $node->get('field_price')->value;
if (empty($price)) {
\Drupal::logger('turnstile')->debug('No price, cannot setup new order');
return FALSE;
}
- // FIXME: support v1 contract terms and use it if we have multiple currencies in $price!
+ // FIXME: support v1 contract terms and use it
+ // if we have multiple currencies in $price!
+ // FIXME: add support for subscriptions
$fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString();
+ /* one day from now */
+ // FIXME: after Merchant v1.1 we can use the returned
+ // the expiration time and then rely on the default already set in
+ // the merchant backend instead of hard-coding 1 day here!
+ $order_expiration = time() + 60 * 60 * 24;
$order_data = [
'order' => [
'amount' => $price,
'summary' => 'Access to: ' . $node->getTitle(),
'fulfillment_url' => $fulfillment_url,
+ 'pay_deadline' => [
+ 't_s' => $order_expiration,
+ ],
],
'create_token' => FALSE,
];
@@ -459,29 +477,57 @@ function turnstile_create_order(NodeInterface $node) {
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
],
+ // Do not throw exceptions on 4xx/5xx status codes
+ 'http_errors' => false,
]);
-
- // Check HTTP status
- if ($response->getStatusCode() !== 200 && $response->getStatusCode() !== 201) {
- \Drupal::logger('turnstile')->debug('Backend failed to setup new order');
+ /* Get JSON result parsed as associative array */
+ $http_status = $response->getStatusCode();
+ $body = $response->getBody();
+ $result = json_decode($body, TRUE);
+ switch ($http_status)
+ {
+ case 200:
+ case 201: /* 201 is not in-spec, but tolerated for now */
+ if (! isset($result['order_id'])) {
+ \Drupal::logger('turnstile')->error('Failed to create order: HTTP success response unexpectedly lacks "order_id" field.');
+ return FALSE;
+ }
+ /* Success, handled below */
+ break;
+ // FIXME: also handle unauthorized!
+ case 404:
+ // FIXME: go into details on why we could get 404 here...
+ $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ \Drupal::logger('turnstile')->error('Failed to create order: @hint (@ec): @body', ['@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@body' => $body_log ?? 'N/A']);
+ return FALSE;
+ case 409:
+ case 410:
+ /* 409: We didn't specify an order, so this should be "wrong currency", which again Turnstile tries to prevent. So this shouldn't be possible. */
+ /* 410: We didn't specify a product, so out-of-stock should also be impossible for Turnstile */
+ $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ \Drupal::logger('turnstile')->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']);
+ return FALSE;
+ case 451:
+ /* KYC required, can happen, warn */
+ \Drupal::logger('turnstile')->warning('Failed to create order as legitimization is required first. Please check legitimization status in your merchant backend.');
+ return FALSE;
+ default:
+ $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ \Drupal::logger('turnstile')->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']);
return FALSE;
}
- $result = json_decode($response->getBody(), TRUE);
-
- if (isset($result['order_id'])) {
- return [
- 'order_id' => $result['order_id'],
- 'payment_url' => $backend_url . 'orders/' . $result['order_id'],
- 'paid' => FALSE,
- 'refunded' => FALSE,
- 'created' => time(), // FIXME: probably better keep when offer expires!
- ];
- }
- \Drupal::logger('turnstile')->debug('Unexpected error trying to setup new order: no order_id returned');
+ $order_id = $result['order_id'];
+ return [
+ 'order_id' => $result['order_id'],
+ 'payment_url' => $backend_url . 'orders/' . $result['order_id'],
+ 'order_expiration' => $order_expiration,
+ 'paid' => FALSE,
+ ];
}
catch (RequestException $e) {
- \Drupal::logger('turnstile')->error('Failed to create Taler order: @message', ['@message' => $e->getMessage()]);
+ $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ \Drupal::logger('turnstile')->error('Failed to create Taler order: @message: @body', ['@message' => $e->getMessage(), '@body' => $body_log ?? 'N/A']);
}
return FALSE;