turnstile

Drupal paywall plugin
Log | Files | Refs | README | LICENSE

TalerMerchantApiService.php (27149B)


      1 <?php
      2 
      3 /**
      4  * @file
      5  * Location: src/TalerMerchantApiService.php
      6  *
      7  * Service for interacting with the Taler Merchant Backend.
      8  */
      9 
     10 namespace Drupal\taler_turnstile;
     11 
     12 use Drupal\Core\Http\ClientFactory;
     13 use Drupal\node\NodeInterface;
     14 use Psr\Log\LoggerInterface;
     15 use Drupal\taler_turnstile\Entity\TurnstilePriceCategory;
     16 use GuzzleHttp\Exception\RequestException;
     17 use Drupal\Core\StringTranslation\StringTranslationTrait;
     18 
     19 
     20 /**
     21  * Taler error codes used in this module. We do not define
     22  * the full list here as that would be excessive and could
     23  * just slow down PHP unnecessarily.
     24  */
     25 enum TalerErrorCode: int {
     26     case TALER_EC_NONE = 0;
     27     case TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN = 2000;
     28     case TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN = 2005;
     29 }
     30 
     31 
     32 /**
     33  * Service for fetching subscriptions and currencies from external API.
     34  */
     35 class TalerMerchantApiService {
     36 
     37   /**
     38    * For i18n, gives us the t() function.
     39    */
     40   use StringTranslationTrait;
     41 
     42   /**
     43    * How long are orders valid by default? 24h.
     44    */
     45   const ORDER_VALIDITY_SECONDS = 86400;
     46 
     47   /**
     48    * How long do we cache /config and token family data from the backend?
     49    */
     50   const CACHE_BACKEND_DATA_SECONDS = 60;
     51 
     52   /**
     53    * The HTTP client factory.
     54    *
     55    * @var \Drupal\Core\Http\ClientFactory
     56    */
     57   protected $httpClientFactory;
     58 
     59   /**
     60    * The logger.
     61    *
     62    * @var \Psr\Log\LoggerInterface
     63    */
     64   protected $logger;
     65 
     66   /**
     67    * Constructs a TalerMerchantApiService object.
     68    *
     69    * @param \Drupal\Core\Http\ClientFactory $http_client_factory
     70    *   The HTTP client factory.
     71    * @param \Psr\Log\LoggerInterface $logger
     72    *   The logger.
     73    */
     74   public function __construct(ClientFactory $http_client_factory, LoggerInterface $logger) {
     75     $this->httpClientFactory = $http_client_factory;
     76     $this->logger = $logger;
     77   }
     78 
     79 
     80   /**
     81    * Return the base URL for the given backend URL (without instance!)
     82    *
     83    * @param string $backend_url
     84    *   Backend URL to check, may include '/instances/$ID' path
     85    * @return string|null
     86    *   base URL, or NULL if the backend URL is invalid
     87    */
     88   private function getBaseURL(string $backend_url) {
     89     if (empty($backend_url)) {
     90       return NULL;
     91     }
     92     if (!str_ends_with($backend_url, '/')) {
     93       return NULL;
     94     }
     95     $parsed_url = parse_url($backend_url);
     96     $path = $parsed_url['path'] ?? '/';
     97     $cleaned_path = preg_replace('#^/instances/[^/]+/?#', '/', $path);
     98     $base = $parsed_url['scheme'] . '://' . $parsed_url['host'];
     99     if (isset($parsed_url['port'])) {
    100       $base .= ':' . $parsed_url['port'];
    101     }
    102     return $base . $cleaned_path;
    103   }
    104 
    105 
    106   /**
    107    * Checks if the given backend URL points to a Taler merchant backend.
    108    *
    109    * @param string $backend_url
    110    *   Backend URL to check, may include '/instances/$ID' path
    111    * @return bool
    112    *   TRUE if this is a valid backend URL for a Taler backend
    113    */
    114   public function checkConfig(string $backend_url) {
    115     $base_url = $this->getBaseURL($backend_url);
    116     if (NULL === $base_url) {
    117       return FALSE;
    118     }
    119     try {
    120       $http_client = $this->httpClientFactory->fromOptions([
    121         'http_errors' => false,
    122         'allow_redirects' => TRUE,
    123         'timeout' => 5, // seconds
    124       ]);
    125       $response = $http_client->get($base_url . 'config');
    126       if ($response->getStatusCode() !== 200) {
    127         return FALSE;
    128       }
    129       $body = json_decode($response->getBody(), TRUE);
    130       return isset($body['name']) && $body['name'] === 'taler-merchant';
    131     } catch (\Exception $e) {
    132       return FALSE;
    133     }
    134   }
    135 
    136 
    137   /**
    138    * Checks if the given backend URL points to a Taler merchant backend.
    139    *
    140    * @param string $backend_url
    141    *   Backend URL to check, may include '/instances/$ID' path
    142    * @param string $access_token
    143    *   Access token to talk to the instance
    144    * @return int
    145    *   HTTP status from a plain GET to the order list,
    146    *   200 or 204 if the backend is configured and accessible,
    147    *   0 on other error, otherwise HTTP status code indicating the error
    148    */
    149   public function checkAccess(string $backend_url, string $access_token) {
    150     try {
    151       $http_client = $this->httpClientFactory->fromOptions([
    152         'headers' => [
    153           'Authorization' => 'Bearer ' . $access_token,
    154         ],
    155         // Do not throw exceptions on 4xx/5xx status codes
    156         'http_errors' => false,
    157         'allow_redirects' => TRUE,
    158         'timeout' => 5, // seconds
    159       ]);
    160       $response = $http_client->get(
    161         $backend_url . 'private/orders?limit=1'
    162       );
    163       return $response->getStatusCode();
    164     } catch (\Exception $e) {
    165       return 0;
    166     }
    167   }
    168 
    169   /**
    170    * Gets the list of available subscriptions.  Always includes a special
    171    * entry for "No reduction" with ID "".
    172    *
    173    * @return array
    174    *   Array mapping token family IDs to subscription data each with a 'name' and 'label' (usually the slug), 'description' and 'description_i18n'.
    175    */
    176   public function getSubscriptions() {
    177     $cid = 'taler_turnstile:subscriptions';
    178     if ($cache = \Drupal::cache()->get($cid)) {
    179       return $cache->data;
    180     }
    181 
    182     // Per default, we always have "no subscription" as an option.
    183     $result = [];
    184     $description = $this->t('No subscription', [], [
    185         'langcode' => 'en', // force English version here!
    186     ]);
    187     $description_i18n = $this->buildTranslationMap (
    188       'No subscription');
    189     $result['%none%'] = [
    190       'name' => 'none',
    191       'label' => 'No reduction',
    192       'description' => $description,
    193       'description_i18n' => $description_i18n,
    194     ];
    195     $config = \Drupal::config('taler_turnstile.settings');
    196     $backend_url = $config->get('payment_backend_url');
    197     $access_token = $config->get('access_token');
    198 
    199     if (empty($backend_url) ||
    200         empty($access_token)) {
    201       $this->logger->debug('No GNU Taler Turnstile backend configured, returning "none" for subscriptions.');
    202       return $result;
    203     }
    204 
    205     $jbody = [];
    206     try {
    207       $http_client = $this->httpClientFactory->fromOptions([
    208         'headers' => [
    209           'Authorization' => 'Bearer ' . $access_token,
    210         ],
    211         // Do not throw exceptions on 4xx/5xx status codes
    212         'http_errors' => false,
    213         'allow_redirects' => TRUE,
    214         'timeout' => 5, // seconds
    215       ]);
    216       $response = $http_client->get($backend_url . 'private/tokenfamilies');
    217       // Get JSON result parsed as associative array
    218       $http_status = $response->getStatusCode();
    219       $body = $response->getBody();
    220       $jbody = json_decode($body, TRUE);
    221       switch ($http_status)
    222       {
    223         case 200:
    224           if (! isset($jbody['token_families'])) {
    225             $this->logger->error('Failed to obtain token family list: HTTP success response unexpectedly lacks "token_families" field.');
    226             return $result;
    227           }
    228           // Success, handled below
    229           break;
    230         case 204:
    231           // empty list
    232           return $result;
    233         case 403:
    234           $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!');
    235           return $result;
    236         case 404:
    237           $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    238           $this->logger->error('Failed to fetch token family list: @hint (@ec): @body', ['@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']);
    239           return $result;
    240         default:
    241           $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    242           $this->logger->error('Unexpected HTTP status code @status trying to fetch token family list: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']);
    243           return $result;
    244       } // end switch on HTTP status
    245 
    246       $tokenFamilies = $jbody['token_families'];
    247       $now = time (); // in seconds since Epoch
    248       foreach ($tokenFamilies as $family) {
    249         $valid_before = ($family['valid_before']['t_s'] === 'never')
    250           ? PHP_INT_MAX
    251           : $family['valid_before']['t_s'];
    252         if ( ($family['kind'] === 'subscription') &&
    253              ($family['valid_after']['t_s'] < $now) &&
    254              ($valid_before >= $now) ) {
    255           $slug = $family['slug'];
    256           $result[$slug] = [
    257             'name' => $family['name'],
    258             'label' => $slug,
    259             'valid_before_s' => $valid_before,
    260             'description' => $family['description'],
    261             'description_i18n' => ($family['description_i18n'] ?? NULL),
    262           ];
    263           $found = TRUE;
    264         }
    265         else
    266         {
    267           $this->logger->info('Token family @slug is not valid right now, skipping it.', ['@slug' => $family['slug']]);
    268         }
    269       }; // end foreach token family
    270       \Drupal::cache()->set($cid,
    271                             $result,
    272                             time() + self::CACHE_BACKEND_DATA_SECONDS);
    273       return $result;
    274     }
    275     catch (RequestException $e) {
    276       $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    277       $this->logger->error('Failed to obtain list of token families: @message: @body', ['@message' => $e->getMessage(), '@body' => $body_log_fmt ?? 'N/A']);
    278     }
    279     return $result;
    280   }
    281 
    282 
    283   /**
    284    * Gets the list of available currencies.
    285    *
    286    * @return array
    287    *   Array of currencies with 'code' (currency code), 'name' and 'label'
    288    *    and 'step' (typically 0 for JPY or 0.01 for EUR/USD).
    289    */
    290   public function getCurrencies() {
    291     $cid = 'taler_turnstile:currencies';
    292     if ($cache = \Drupal::cache()->get($cid)) {
    293       return $cache->data;
    294     }
    295 
    296     $config = \Drupal::config('taler_turnstile.settings');
    297     $payment_backend_url = $config->get('payment_backend_url');
    298 
    299     if (empty($payment_backend_url)) {
    300       $this->logger->error('Taler merchant backend not configured; cannot obtain currency list');
    301       return [];
    302     }
    303 
    304     try {
    305       // Fetch backend configuration.
    306       $http_client = $this->httpClientFactory->fromOptions([
    307         'allow_redirects' => TRUE,
    308         'http_errors' => FALSE,
    309         'allow_redirects' => TRUE,
    310         'timeout' => 5, // seconds
    311       ]);
    312 
    313       $config_url = $payment_backend_url . 'config';
    314       $response = $http_client->get($config_url);
    315 
    316       if ($response->getStatusCode() !== 200) {
    317         $this->logger->error('Taler merchant backend did not respond; cannot obtain currency list');
    318         return [];
    319       }
    320 
    321       $backend_config = json_decode($response->getBody(), TRUE);
    322       if (!$backend_config || !is_array($backend_config)) {
    323         // Invalid response, fallback to grant_access_on_error setting.
    324         $this->logger->error('Taler merchant backend returned invalid /config response; cannot obtain currency list');
    325         return [];
    326       }
    327 
    328       if (! isset($backend_config['currencies']))
    329       {
    330         $this->logger->error('Backend returned malformed response for /config');
    331         return [];
    332       }
    333 
    334       // Parse and validate each amount in the comma-separated list.
    335       $currencies = $backend_config['currencies'];
    336 
    337       $result = array_map(function ($currency) {
    338         return [
    339             'code' => $currency['currency'],
    340             'name' => $currency['name'],
    341             'label' => $currency['alt_unit_names'][0] ?? $currency['id'],
    342             'step' => pow(0.1, $currency['num_fractional_input_digits'] ?? 2),
    343           ];
    344         },
    345         $currencies
    346       );
    347 
    348       \Drupal::cache()->set($cid, $result, time() + self::CACHE_BACKEND_DATA_SECONDS);
    349       return $result;
    350     } catch (\Exception $e) {
    351 
    352       // On exception, fall back to grant_access_on_error setting.
    353       $this->logger->error('Failed to validate obtain configuration from backend: @error', [
    354         '@error' => $e->getMessage(),
    355       ]);
    356       return [];
    357     }
    358   }
    359 
    360 
    361   /**
    362    * Check order status with Taler backend.
    363    *
    364    * @param string $order_id
    365    *   The order ID to check.
    366    *
    367    * @return array|FALSE
    368    *   Order status information or FALSE on failure.
    369    */
    370   public function checkOrderStatus($order_id) {
    371     $config = \Drupal::config('taler_turnstile.settings');
    372     $backend_url = $config->get('payment_backend_url');
    373     $access_token = $config->get('access_token');
    374 
    375     if (empty($backend_url) ||
    376         empty($access_token)) {
    377       $this->logger->debug('No GNU Taler Turnstile backend configured, cannot check order status!');
    378       return FALSE;
    379     }
    380 
    381     try {
    382       $http_client = $this->httpClientFactory->fromOptions([
    383         'headers' => [
    384           'Authorization' => 'Bearer ' . $access_token,
    385         ],
    386         // Do not throw exceptions on 4xx/5xx status codes
    387         'http_errors' => false,
    388         'allow_redirects' => TRUE,
    389         'timeout' => 5, // seconds
    390       ]);
    391       $response = $http_client->get($backend_url . 'private/orders/' . $order_id);
    392 
    393       $http_status = $response->getStatusCode();
    394       $body = $response->getBody();
    395       $jbody = json_decode($body, TRUE);
    396       switch ($http_status)
    397       {
    398         case 200:
    399           $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    400           $this->logger->debug('Got existing contract: @body', ['@body' => $body_log_fmt ?? 'N/A']);
    401           // Success, handled below
    402           break;
    403         case 403:
    404           $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!');
    405           return FALSE;
    406         case 404:
    407           // Order unknown or instance unknown
    408           /** @var TalerErrorCode $ec */
    409           $ec = TalerErrorCode::tryFrom ($jbody['code']) ?? TalerErrorCode::TALER_EC_NONE;
    410           switch ($ec)
    411           {
    412             case TalerErrorCode::TALER_EC_NONE:
    413               // Protocol violation. Could happen if the backend domain was
    414               // taken over by someone else.
    415               $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    416               $this->logger->error('Invalid response from merchant backend when trying to obtain order status. Check your GNU Taler Turnstile configuration! @body', ['@body' => $body_log_fmt ?? 'N/A']);
    417               return FALSE;
    418             case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN:
    419               // This could happen if our instance was deleted after the configuration was
    420               // checked. Very bad, log serious error.
    421               $this->logger->error('Configured instance "@detail" unknown to merchant backend. Check your GNU Taler Turnstile configuration!', ['@detail' => $jbody['detail'] ?? 'N/A']);
    422               return FALSE;
    423             case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN:
    424               // This could happen if the instance owner manually deleted
    425               // an order while the customer was looking at the article.
    426               $this->logger->warning('Order "@order" disappeared in the backend.', ['@order' => $order_id]);
    427               return FALSE;
    428             default:
    429               $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    430               $this->logger->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' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']);
    431               return FALSE;
    432           }
    433         default:
    434           // Internal server errors and the like...
    435           $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    436           $this->logger->error('Unexpected HTTP status code @status from Taler merchant backend when trying to get order status: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']);
    437           return FALSE;
    438       }
    439 
    440 
    441       $order_status = $jbody['order_status'] ?? 'unknown';
    442       $subscription_expiration = 0;
    443       $subscription_slug = FALSE;
    444       $pay_deadline = 0;
    445       $paid = FALSE;
    446       switch ($order_status)
    447         {
    448           case 'unpaid':
    449             // 'pay_deadline' is only available since v21 rev 1, so for now we
    450             // fall back to creation_time + offset. FIXME later!
    451             $pay_deadline = $jbody['pay_deadline']['t_s'] ??
    452                             (self::ORDER_VALIDITY_SECONDS + $jbody['creation_time']['t_s'] ?? 0);
    453             break;
    454           case 'claimed':
    455             $contract_terms = $jbody['contract_terms'];
    456             $pay_deadline = $contract_terms['pay_deadline']['t_s'] ?? 0;
    457             break;
    458           case 'paid':
    459             $paid = TRUE;
    460             $contract_terms = $jbody['contract_terms'];
    461             $contract_version = $jbody['version'] ?? 0;
    462             $now = time();
    463             switch ($contract_version) {
    464               case 0:
    465                 $this->logger->warning('Got unexpected v0 contract version');
    466                 break;
    467               case 1:
    468                 $choice_index = $jbody['choice_index'] ?? 0;
    469                 $token_families = $contract_terms['token_families'];
    470                 $contract_choice = $contract_terms['choices'][$choice_index];
    471                 $outputs = $contract_choice['outputs'];
    472                 $found = FALSE;
    473                 foreach ($outputs as $output) {
    474                   $slug = $output['token_family_slug'];
    475                   $token_family = $token_families[$slug];
    476                   $details = $token_family['details'];
    477                   if ('subscription' !== $details['class']) {
    478                     continue;
    479                   }
    480                   $keys = $token_family['keys'];
    481                   foreach ($keys as $key) {
    482                     $signature_validity_start = $key['signature_validity_start']['t_s'];
    483                     $signature_validity_end = $key['signature_validity_end']['t_s'];
    484                     if ( ($signature_validity_start <= $now) &&
    485                          ($signature_validity_end > $now) )
    486                     {
    487                       // Theoretically, one contract could buy multiple
    488                       // subscriptions. But GNU Taler Turnstile does not
    489                       // generate such contracts and we do not support
    490                       // that case here.
    491                       $subscription_slug = $slug;
    492                       $subscription_expiration = $signature_validity_end;
    493                       $found = TRUE;
    494                       break;
    495                     }
    496                   } // end of for each key
    497                   if ($found)
    498                     break;
    499                 } // end of for each output
    500                 break;
    501               default:
    502                 $this->logger->error('Got unsupported contract version "@version"', ['@version' => $contract_version]);
    503                 break;
    504             } // end switch on contract version
    505             break;
    506          default:
    507            $this->logger->error('Got unexpected order status "@status"', ['@status' => $order_status]);
    508            break;
    509         } // switch on $order_status
    510       return [
    511         'order_id' => $order_id,
    512         'paid' => $paid,
    513         'subscription_slug' => $subscription_slug,
    514         'subscription_expiration' => $subscription_expiration,
    515         'order_expiration' => $pay_deadline,
    516       ];
    517     }
    518     catch (RequestException $e) {
    519       // Any kind of error that is outside of the spec.
    520       $this->logger->error('Failed to check order status: @message', ['@message' => $e->getMessage()]);
    521       return FALSE;
    522     }
    523   }
    524 
    525 
    526   /**
    527    * Create a new Taler order.
    528    *
    529    * @param \Drupal\node\NodeInterface $node
    530    *   The node to create an order for.
    531    *
    532    * @return array|FALSE
    533    *   Order information or FALSE on failure.
    534    */
    535   public function createOrder(NodeInterface $node) {
    536     $config = \Drupal::config('taler_turnstile.settings');
    537     $backend_url = $config->get('payment_backend_url');
    538     $access_token = $config->get('access_token');
    539 
    540     if (empty($backend_url) || empty($access_token)) {
    541       $this->logger->debug('No backend, cannot setup new order');
    542       return FALSE;
    543     }
    544 
    545     /** @var \Drupal\Core\Field\EntityReferenceFieldItemList $field */
    546     $field = $node->get('field_taler_turnstile_prcat');
    547     if ($field->isEmpty()) {
    548       $this->logger->debug('No price category selected');
    549       return FALSE;
    550     }
    551 
    552     /** @var TurnstilePriceCategory $price_category */
    553     $price_category = $field->entity;
    554     if (! $price_category) {
    555       $this->logger->debug('No price category, cannot setup new order');
    556       return FALSE;
    557     }
    558     $subscriptions = $this->getSubscriptions();
    559     $choices = $price_category->getPaymentChoices($subscriptions);
    560     if (empty($choices)) {
    561       $this->logger->debug('Price list is empty, cannot setup new order');
    562       return FALSE;
    563     }
    564 
    565     $fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString();
    566 
    567     // Get (hashed) session ID
    568     $hashed_session_id = $this->getHashedSessionId();
    569     $this->logger->debug('Taler session is @session', ['@session' => $hashed_session_id]);
    570 
    571     // FIXME: after Merchant v1.1 we can use the returned
    572     // the expiration time and then rely on the default already set in
    573     // the merchant backend instead of hard-coding 1 day here!
    574     $order_expiration = time() + self::ORDER_VALIDITY_SECONDS;
    575     $order_data = [
    576       'order' => [
    577         'version' => 1,
    578         'choices' => $choices,
    579         'summary' => 'Access to: ' . $node->getTitle(),
    580         'summary_i18n' => $this->buildTranslationMap ('Access to: @title',
    581            ['@title' => $node->getTitle()]),
    582         'fulfillment_url' => $fulfillment_url,
    583         'pay_deadline' => [
    584           't_s' => $order_expiration,
    585         ],
    586       ],
    587       'session_id' => $hashed_session_id,
    588       'create_token' => FALSE,
    589     ];
    590 
    591     $jbody = [];
    592     try {
    593       $http_client = $this->httpClientFactory->fromOptions ([
    594         'headers' => [
    595           'Authorization' => 'Bearer ' . $access_token,
    596           'Content-Type' => 'application/json',
    597         ],
    598         // Do not throw exceptions on 4xx/5xx status codes
    599         'http_errors' => false,
    600         'allow_redirects' => TRUE,
    601         'timeout' => 5, // seconds
    602       ]);
    603       $response = $http_client->post($backend_url . 'private/orders', [
    604         'json' => $order_data,
    605       ]);
    606       // Get JSON result parsed as associative array
    607       $http_status = $response->getStatusCode();
    608       $body = $response->getBody();
    609       $jbody = json_decode($body, TRUE);
    610       switch ($http_status)
    611       {
    612         case 200:
    613           if (! isset($jbody['order_id'])) {
    614             $this->logger->error('Failed to create order: HTTP success response unexpectedly lacks "order_id" field.');
    615             return FALSE;
    616           }
    617           // Success, handled below
    618           break;
    619         case 403:
    620           $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!');
    621           return FALSE;
    622         case 404:
    623           $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    624           $this->logger->error('Failed to create order: @hint (@ec): @body', ['@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']);
    625           return FALSE;
    626         case 409:
    627         case 410:
    628           // 409: We didn't specify an order, so this should be "wrong currency", which again GNU Taler Turnstile tries to prevent. So this shouldn't be possible.
    629           // 410: We didn't specify a product, so out-of-stock should also be impossible for GNU Taler Turnstile
    630           $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    631           $this->logger->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']);
    632           return FALSE;
    633         case 451:
    634           // KYC required, can happen, warn
    635           $this->logger->warning('Failed to create order as legitimization is required first. Please check legitimization status in your merchant backend.');
    636           return FALSE;
    637         default:
    638           $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    639           $this->logger->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']);
    640           return FALSE;
    641       } // end switch on HTTP status
    642 
    643       $order_id = $jbody['order_id'];
    644       return [
    645         'order_id' => $order_id,
    646         'payment_url' => $backend_url . 'orders/' . $order_id,
    647         'order_expiration' => $order_expiration,
    648         'paid' => FALSE,
    649         'session_id' => $hashed_session_id,
    650       ];
    651     }
    652     catch (RequestException $e) {
    653       $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    654       $this->logger->error('Failed to create Taler order: @message: @body', ['@message' => $e->getMessage(), '@body' => $body_log_fmt ?? 'N/A']);
    655     }
    656 
    657     return FALSE;
    658   }
    659 
    660 
    661   /**
    662    * Build a translation map for all enabled languages.
    663    *
    664    * @param string $string
    665    *   The translatable string.
    666    * @param array $args
    667    *   Placeholder replacements.
    668    *
    669    * @return array
    670    *   Map of language codes to translated strings.
    671    */
    672   private function buildTranslationMap(string $string, array $args = []): array {
    673     $translations = [];
    674     $language_manager = \Drupal::languageManager();
    675 
    676     foreach ($language_manager->getLanguages() as $langcode => $language) {
    677       $translation = $this->t($string, $args, [
    678         'langcode' => $langcode,
    679       ]);
    680       $translations[$langcode] = (string) $translation;
    681     }
    682     return $translations;
    683   }
    684 
    685 
    686   /**
    687    * Generate a hashed session identifier for payment tracking.
    688    *
    689    * This creates a deterministic hash from the PHP session ID that can be
    690    * safely shared with the client and merchant backend as the
    691    * Taler "session_id".
    692    *
    693    * @return string
    694    *   Base64-encoded SHA-256 hash of the session ID (URL-safe).
    695    */
    696   private function getHashedSessionId(): string {
    697     $raw_session_id = session_id();
    698     if (empty($raw_session_id)) {
    699       // If no session exists, start one
    700       if (session_status() === PHP_SESSION_NONE) {
    701         session_start();
    702         $raw_session_id = session_id();
    703       }
    704     }
    705 
    706     $hash = hash('sha256', $raw_session_id, true);
    707     // Encode as URL-safe base64: replace +/ with -_ and remove padding
    708     return rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');
    709   }
    710 
    711 
    712 }