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 }