class-taler-merchant-api.php (26475B)
1 <?php 2 /** 3 * Taler Merchant API Service 4 * 5 * Handles communication with the GNU Taler merchant backend. 6 */ 7 8 if (!defined('ABSPATH')) { 9 exit; 10 } 11 12 /** 13 * Taler error codes used in this module 14 */ 15 class Taler_Error_Code { 16 const TALER_EC_NONE = 0; 17 const TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN = 2000; 18 const TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN = 2005; 19 } 20 21 class Taler_Merchant_API { 22 23 /** 24 * How long are orders valid by default? 24h. 25 */ 26 const ORDER_VALIDITY_SECONDS = 86400; 27 28 /** 29 * How long do we cache /config and token family data from the backend? 30 */ 31 const CACHE_BACKEND_DATA_SECONDS = 60; 32 33 /** 34 * Return the base URL for the given backend URL (without instance!) 35 * 36 * @param string $backend_url Backend URL to check, may include '/instances/$ID' path 37 * @return string|null Base URL, or NULL if the backend URL is invalid 38 */ 39 private static function get_base_url($backend_url) { 40 if (empty($backend_url)) { 41 return null; 42 } 43 44 if (!str_ends_with($backend_url, '/')) { 45 return null; 46 } 47 48 $parsed_url = wp_parse_url($backend_url); 49 $path = isset($parsed_url['path']) ? $parsed_url['path'] : '/'; 50 $cleaned_path = preg_replace('#^/instances/[^/]+/?#', '/', $path); 51 52 $base = $parsed_url['scheme'] . '://' . $parsed_url['host']; 53 54 if (isset($parsed_url['port'])) { 55 $base .= ':' . $parsed_url['port']; 56 } 57 58 return $base . $cleaned_path; 59 } 60 61 /** 62 * Check if the payment backend URL is valid 63 * 64 * @param string $url Backend URL to check 65 * @return bool TRUE if this is a valid backend URL for a Taler backend 66 */ 67 public static function check_config($url) { 68 $base_url = self::get_base_url($url); 69 70 if ($base_url === null) { 71 return false; 72 } 73 74 try { 75 $response = wp_remote_get($base_url . 'config', array( 76 'timeout' => 5, 77 'redirection' => 5 78 )); 79 80 if (is_wp_error($response)) { 81 return false; 82 } 83 84 if (wp_remote_retrieve_response_code($response) !== 200) { 85 return false; 86 } 87 88 $body = wp_remote_retrieve_body($response); 89 $data = json_decode($body, true); 90 91 return isset($data['name']) && $data['name'] === 'taler-merchant'; 92 } catch (Exception $e) { 93 return false; 94 } 95 } 96 97 /** 98 * Check access to the merchant backend 99 * 100 * @param string $backend_url Backend URL to check 101 * @param string $access_token Access token to talk to the instance 102 * @return int HTTP status code (200/204 if successful, 0 on error) 103 */ 104 public static function check_access($backend_url, $access_token) { 105 try { 106 $args = array( 107 'timeout' => 5, 108 'redirection' => 5, 109 'headers' => array() 110 ); 111 112 if (!empty($access_token)) { 113 $args['headers']['Authorization'] = 'Bearer ' . $access_token; 114 } 115 116 $response = wp_remote_get($backend_url . 'private/orders?limit=1', $args); 117 118 if (is_wp_error($response)) { 119 return 0; 120 } 121 122 return wp_remote_retrieve_response_code($response); 123 } catch (Exception $e) { 124 return 0; 125 } 126 } 127 128 /** 129 * Get available subscriptions from the backend 130 * 131 * @return array Array mapping token family IDs to subscription data 132 */ 133 public static function get_subscriptions() { 134 $cache_key = 'taler_turnstile_subscriptions'; 135 $cached = get_transient($cache_key); 136 137 if ($cached !== false) { 138 return $cached; 139 } 140 141 // Default: always include "no subscription" option 142 $result = array( 143 '%none%' => array( 144 'name' => 'none', 145 'label' => __('No reduction', 'taler-turnstile'), 146 'description' => __('No subscription', 'taler-turnstile'), 147 'description_i18n' => self::build_translation_map(__('No subscription', 'taler-turnstile')) 148 ) 149 ); 150 151 $backend_url = get_option('taler_turnstile_payment_backend_url'); 152 $access_token = get_option('taler_turnstile_access_token'); 153 154 if (empty($backend_url) || empty($access_token)) { 155 error_log('No GNU Taler Turnstile backend configured, returning "none" for subscriptions.'); 156 return $result; 157 } 158 159 try { 160 $args = array( 161 'timeout' => 5, 162 'redirection' => 5, 163 'headers' => array( 164 'Authorization' => 'Bearer ' . $access_token 165 ) 166 ); 167 168 $response = wp_remote_get($backend_url . 'private/tokenfamilies', $args); 169 170 if (is_wp_error($response)) { 171 error_log('Failed to obtain token family list: ' . $response->get_error_message()); 172 return $result; 173 } 174 175 $http_status = wp_remote_retrieve_response_code($response); 176 $body = wp_remote_retrieve_body($response); 177 $jbody = json_decode($body, true); 178 179 switch ($http_status) { 180 case 200: 181 if (!isset($jbody['token_families'])) { 182 error_log('Failed to obtain token family list: HTTP success response unexpectedly lacks "token_families" field.'); 183 return $result; 184 } 185 break; 186 187 case 204: 188 // Empty list 189 set_transient($cache_key, $result, self::CACHE_BACKEND_DATA_SECONDS); 190 return $result; 191 192 case 403: 193 error_log('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!'); 194 return $result; 195 196 case 404: 197 error_log('Failed to fetch token family list: ' . json_encode($jbody)); 198 return $result; 199 200 default: 201 error_log('Unexpected HTTP status code ' . $http_status . ' trying to fetch token family list'); 202 return $result; 203 } 204 205 $token_families = $jbody['token_families']; 206 $now = time (); // in seconds since Epoch 207 foreach ($token_families as $family) { 208 $valid_before = ($family['valid_before']['t_s'] === 'never') 209 ? PHP_INT_MAX 210 : $family['valid_before']['t_s']; 211 if ( ($family['kind'] === 'subscription') && 212 ($valid_before >= $now) && 213 ($family['valid_after']['t_s'] < $now) ) { 214 $slug = $family['slug']; 215 $result[$slug] = array( 216 'name' => $family['name'], 217 'label' => $slug, 218 'valid_before_s' => $valid_before, 219 'description' => $family['description'] ?? 'no description (outdated backend)', 220 'description_i18n' => ($family['description_i18n'] ?? NULL) 221 ); 222 } 223 } 224 225 set_transient($cache_key, $result, self::CACHE_BACKEND_DATA_SECONDS); 226 return $result; 227 228 } catch (Exception $e) { 229 error_log('Failed to obtain list of token families: ' . $e->getMessage()); 230 } 231 232 return $result; 233 } 234 235 /** 236 * Get available currencies from the backend 237 * 238 * @return array Array of currencies with code, name, label, and step 239 */ 240 public static function get_currencies() { 241 $cache_key = 'taler_turnstile_currencies'; 242 $cached = get_transient($cache_key); 243 244 if ($cached !== false) { 245 return $cached; 246 } 247 248 $backend_url = get_option('taler_turnstile_payment_backend_url'); 249 250 if (empty($backend_url)) { 251 error_log('Taler merchant backend not configured; cannot obtain currency list'); 252 return array(); 253 } 254 255 try { 256 $config_url = $backend_url . 'config'; 257 $response = wp_remote_get($config_url, array( 258 'timeout' => 5, 259 'redirection' => 5 260 )); 261 262 if (is_wp_error($response)) { 263 error_log('Failed to fetch backend config: ' . $response->get_error_message()); 264 return array(); 265 } 266 267 if (wp_remote_retrieve_response_code($response) !== 200) { 268 error_log('Taler merchant backend did not respond; cannot obtain currency list'); 269 return array(); 270 } 271 272 $body = wp_remote_retrieve_body($response); 273 $backend_config = json_decode($body, true); 274 275 if (!$backend_config || !is_array($backend_config)) { 276 error_log('Taler merchant backend returned invalid /config response'); 277 return array(); 278 } 279 280 if (!isset($backend_config['currencies'])) { 281 error_log('Backend returned malformed response for /config'); 282 return array(); 283 } 284 285 $currencies = $backend_config['currencies']; 286 287 $result = array_map(function($currency) { 288 return array( 289 'code' => $currency['currency'], 290 'name' => $currency['name'], 291 'label' => isset($currency['alt_unit_names'][0]) ? $currency['alt_unit_names'][0] : $currency['currency'], 292 'step' => pow(0.1, isset($currency['num_fractional_input_digits']) ? $currency['num_fractional_input_digits'] : 2) 293 ); 294 }, $currencies); 295 296 set_transient($cache_key, $result, self::CACHE_BACKEND_DATA_SECONDS); 297 return $result; 298 299 } catch (Exception $e) { 300 error_log('Failed to obtain configuration from backend: ' . $e->getMessage()); 301 return array(); 302 } 303 } 304 305 /** 306 * Check order status with Taler backend 307 * 308 * @param string $order_id The order ID to check 309 * @return array|false Order status information or false on failure 310 */ 311 public static function check_order_status($order_id) { 312 $backend_url = get_option('taler_turnstile_payment_backend_url'); 313 $access_token = get_option('taler_turnstile_access_token'); 314 315 if (empty($backend_url) || empty($access_token)) { 316 error_log('No GNU Taler Turnstile backend configured, cannot check order status!'); 317 return false; 318 } 319 320 try { 321 $args = array( 322 'timeout' => 5, 323 'redirection' => 5, 324 'headers' => array( 325 'Authorization' => 'Bearer ' . $access_token 326 ) 327 ); 328 329 $response = wp_remote_get($backend_url . 'private/orders/' . $order_id, $args); 330 331 if (is_wp_error($response)) { 332 error_log('Failed to check order status: ' . $response->get_error_message()); 333 return false; 334 } 335 336 $http_status = wp_remote_retrieve_response_code($response); 337 $body = wp_remote_retrieve_body($response); 338 $jbody = json_decode($body, true); 339 340 switch ($http_status) { 341 case 200: 342 // Success 343 break; 344 345 case 403: 346 error_log('Access denied by the merchant backend. Check your GNU Taler Turnstile configuration!'); 347 return false; 348 349 case 404: 350 $ec = isset($jbody['code']) ? $jbody['code'] : Taler_Error_Code::TALER_EC_NONE; 351 352 switch ($ec) { 353 case Taler_Error_Code::TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN: 354 error_log('Configured instance unknown to merchant backend. Check your configuration!'); 355 return false; 356 357 case Taler_Error_Code::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN: 358 error_log('Order ' . $order_id . ' disappeared in the backend.'); 359 return false; 360 361 default: 362 error_log('Unexpected error checking order status: ' . json_encode($jbody)); 363 return false; 364 } 365 366 default: 367 error_log('Unexpected HTTP status code ' . $http_status . ' from merchant backend'); 368 return false; 369 } 370 371 $order_status = isset($jbody['order_status']) ? $jbody['order_status'] : 'unknown'; 372 $subscription_expiration = 0; 373 $subscription_slug = false; 374 $pay_deadline = 0; 375 $paid = false; 376 377 switch ($order_status) { 378 case 'unpaid': 379 $pay_deadline = isset($jbody['pay_deadline']['t_s']) 380 ? $jbody['pay_deadline']['t_s'] 381 : (self::ORDER_VALIDITY_SECONDS + (isset($jbody['creation_time']['t_s']) ? $jbody['creation_time']['t_s'] : 0)); 382 break; 383 384 case 'claimed': 385 $contract_terms = $jbody['contract_terms']; 386 $pay_deadline = isset($contract_terms['pay_deadline']['t_s']) ? $contract_terms['pay_deadline']['t_s'] : 0; 387 break; 388 389 case 'paid': 390 $paid = true; 391 $contract_terms = $jbody['contract_terms']; 392 $contract_version = isset($jbody['version']) ? $jbody['version'] : 0; 393 $now = time(); 394 395 if ($contract_version === 1) { 396 $choice_index = isset($jbody['choice_index']) ? $jbody['choice_index'] : 0; 397 $token_families = $contract_terms['token_families']; 398 $contract_choice = $contract_terms['choices'][$choice_index]; 399 $outputs = isset($contract_choice['outputs']) ? $contract_choice['outputs'] : array(); 400 401 foreach ($outputs as $output) { 402 $slug = $output['token_family_slug']; 403 $token_family = $token_families[$slug]; 404 $details = $token_family['details']; 405 406 if (isset($details['class']) && $details['class'] !== 'subscription') { 407 continue; 408 } 409 410 $keys = $token_family['keys']; 411 412 foreach ($keys as $key) { 413 $sig_start = $key['signature_validity_start']['t_s']; 414 $sig_end = $key['signature_validity_end']['t_s']; 415 416 if ($sig_start <= $now && $sig_end > $now) { 417 $subscription_slug = $slug; 418 $subscription_expiration = $sig_end; 419 break 2; 420 } 421 } 422 } 423 } 424 break; 425 426 default: 427 error_log('Got unexpected order status: ' . $order_status); 428 break; 429 } 430 431 return array( 432 'order_id' => $order_id, 433 'paid' => $paid, 434 'subscription_slug' => $subscription_slug, 435 'subscription_expiration' => $subscription_expiration, 436 'order_expiration' => $pay_deadline 437 ); 438 439 } catch (Exception $e) { 440 error_log('Failed to check order status: ' . $e->getMessage()); 441 return false; 442 } 443 } 444 445 /** 446 * Create a new Taler order 447 * 448 * @param int $post_id The post ID to create an order for 449 * @return array|false Order information or false on failure 450 */ 451 public static function create_order($post_id) { 452 $backend_url = get_option('taler_turnstile_payment_backend_url'); 453 $access_token = get_option('taler_turnstile_access_token'); 454 if (empty($backend_url) || empty($access_token)) { 455 error_log('No backend, cannot setup new order'); 456 return false; 457 } 458 459 $price_category_id = get_post_meta($post_id, '_taler_price_category', true); 460 if (empty($price_category_id)) { 461 error_log('No price category selected'); 462 return false; 463 } 464 465 $price_category = Taler_Price_Category::get($price_category_id); 466 if (!$price_category) { 467 error_log('No price category, cannot setup new order'); 468 return false; 469 } 470 $price_hint = Taler_Price_Category::getPriceHint($price_category_id); 471 $subscription_hint = Taler_Price_Category::getSubscriptionHint($price_category_id); 472 473 $subscriptions = Taler_Merchant_API::get_subscriptions(); 474 $choices = Taler_Price_Category::get_payment_choices($price_category_id, $subscriptions); 475 if (empty($choices)) { 476 error_log('Price list is empty, cannot setup new order'); 477 return false; 478 } 479 480 $fulfillment_url = get_permalink($post_id); 481 $hashed_session_id = self::get_hashed_session_id(); 482 483 $order_expiration = time() + self::ORDER_VALIDITY_SECONDS; 484 485 $order_data = array( 486 'order' => array( 487 'version' => 1, 488 'choices' => $choices, 489 'summary' => 'Access to: ' . get_the_title($post_id), 490 'summary_i18n' => self::build_translation_map (__('Access to: @title', 'taler-turnstile'), 491 ['@title' => get_the_title($post_id)]), 492 'fulfillment_url' => $fulfillment_url, 493 'pay_deadline' => array( 494 't_s' => $order_expiration 495 ) 496 ), 497 'session_id' => $hashed_session_id, 498 'create_token' => false 499 ); 500 501 try { 502 $args = array( 503 'timeout' => 5, 504 'redirection' => 5, 505 'headers' => array( 506 'Authorization' => 'Bearer ' . $access_token, 507 'Content-Type' => 'application/json' 508 ), 509 'body' => json_encode($order_data) 510 ); 511 512 $response = wp_remote_post($backend_url . 'private/orders', $args); 513 514 if (is_wp_error($response)) { 515 error_log('Failed to create Taler order: ' . $response->get_error_message()); 516 return false; 517 } 518 519 $http_status = wp_remote_retrieve_response_code($response); 520 $body = wp_remote_retrieve_body($response); 521 $jbody = json_decode($body, true); 522 523 switch ($http_status) { 524 case 200: 525 if (!isset($jbody['order_id'])) { 526 error_log('Failed to create order: response lacks "order_id" field.'); 527 return false; 528 } 529 break; 530 531 case 403: 532 error_log('Access denied by the merchant backend. Check your configuration!'); 533 return false; 534 535 case 451: 536 error_log('Failed to create order as legitimization is required first.'); 537 return false; 538 539 default: 540 error_log('Unexpected HTTP status code ' . $http_status . ' trying to create order'); 541 return false; 542 } 543 544 $order_id = $jbody['order_id']; 545 546 return array( 547 'order_id' => $order_id, 548 'payment_url' => $backend_url . 'orders/' . $order_id, 549 'order_expiration' => $order_expiration, 550 'price_hint' => $price_hint, 551 'subscription_hint' => $subscription_hint, 552 'paid' => false, 553 'session_id' => $hashed_session_id 554 ); 555 556 } catch (Exception $e) { 557 error_log('Failed to create Taler order: ' . $e->getMessage()); 558 return false; 559 } 560 } 561 562 563 /** 564 * Build a translation map for all enabled languages 565 * 566 * Detects available multilingual plugins and generates translations accordingly. 567 * Supports: WPML, Polylang, TranslatePress, qTranslate-XT 568 * 569 * NOTE: This was not yet tested properly with the respective 570 * translation engines. Reports on issues particularly welcome. 571 * 572 * @param string $string The translatable string 573 * @param array $args placeholder replacements for the translatable string 574 * @return array Map of language codes to translated strings 575 */ 576 private static function build_translation_map($string, $args = []) { 577 $translations = array(); 578 579 // Detect and use WPML (WordPress Multilingual Plugin) 580 if (defined('ICL_LANGUAGE_CODE') && function_exists('icl_translate')) { 581 global $sitepress; 582 583 if ($sitepress) { 584 $active_languages = $sitepress->get_active_languages(); 585 586 foreach ($active_languages as $lang_code => $lang_data) { 587 // Register string if not already registered 588 do_action('wpml_register_single_string', 'taler-turnstile', 'subscription_description', $string); 589 590 // Get translation 591 $translated = apply_filters('wpml_translate_single_string', $string, 'taler-turnstile', 'subscription_description', $lang_code); 592 $translated = self::apply_placeholder_replacements($translated, $args); 593 $translations[$lang_code] = $translated; 594 } 595 596 return $translations; 597 } 598 } 599 600 // Detect and use Polylang 601 if (function_exists('pll_languages_list') && function_exists('pll_translate_string')) { 602 $languages = pll_languages_list(); 603 604 foreach ($languages as $lang_code) { 605 // Register string for translation 606 pll_register_string('subscription_description', $string, 'taler-turnstile'); 607 608 // Get translation 609 $translated = pll_translate_string($string, $lang_code); 610 $translated = self::apply_placeholder_replacements($translated, $args); 611 $translations[$lang_code] = $translated; 612 } 613 614 return $translations; 615 } 616 617 // Detect and use TranslatePress 618 if (class_exists('TRP_Translate_Press')) { 619 // Note to reviewers: this is NOT a symbol of ours, but 620 // one from "TRP_Translate_Press" which we are *importing* here. 621 global $TRP_LANGUAGE; 622 623 $trp = TRP_Translate_Press::get_trp_instance(); 624 $settings = $trp->get_component('settings'); 625 626 if ($settings) { 627 $trp_settings = $settings->get_settings(); 628 $languages = isset($trp_settings['publish-languages']) ? $trp_settings['publish-languages'] : array(); 629 630 $trp_query = $trp->get_component('query'); 631 632 foreach ($languages as $lang_code) { 633 // Get translation from TranslatePress database 634 $translated = $trp_query->get_existing_translation( 635 array( 636 'original' => $string, 637 'language' => $lang_code 638 ) 639 ); 640 $translated = self::apply_placeholder_replacements($translated, $args); 641 $translations[$lang_code] = !empty($translated) ? $translated : $string; 642 } 643 644 return $translations; 645 } 646 } 647 648 // Detect and use qTranslate-XT (or qTranslate-X) 649 if (function_exists('qtranxf_getLanguage') && function_exists('qtranxf_getEnabledLanguages')) { 650 $languages = qtranxf_getEnabledLanguages(); 651 652 foreach ($languages as $lang_code) { 653 // qTranslate uses quicktags format [:en]text[:de]text 654 // For dynamic strings, we'll use the string as-is for all languages 655 // unless it's already in quicktag format 656 if (preg_match('/\[:.+\]/', $string)) { 657 $translated = qtranxf_use($lang_code, $string); 658 } else { 659 $translated = $string; 660 } 661 $translated = self::apply_placeholder_replacements($translated, $args); 662 $translations[$lang_code] = $translated; 663 } 664 return $translations; 665 } 666 667 // Fallback: No multilingual plugin detected 668 // Return English only with placeholder replacements 669 $translated = self::apply_placeholder_replacements($string, $args); 670 return array('en' => $translated); 671 } 672 673 /** 674 * Apply placeholder replacements to a translated string 675 * 676 * @param string $string The string containing placeholders (e.g., "Kaufe @title") 677 * @param array $args Placeholder replacements (e.g., ['@title' => '1984']) 678 * @return string String with placeholders replaced 679 */ 680 private static function apply_placeholder_replacements($string, $args) { 681 if (empty($args)) { 682 return $string; 683 } 684 foreach ($args as $placeholder => $value) { 685 $string = str_replace($placeholder, $value, $string); 686 } 687 return $string; 688 } 689 690 /** 691 * Generate a hashed session identifier for payment tracking 692 * 693 * @return string Base64-encoded SHA-256 hash of the session ID (URL-safe) 694 */ 695 private static function get_hashed_session_id() { 696 if (session_status() === PHP_SESSION_NONE) { 697 session_start(); 698 } 699 700 $raw_session_id = session_id(); 701 702 if (empty($raw_session_id)) { 703 $raw_session_id = wp_get_session_token(); 704 } 705 706 $hash = hash('sha256', $raw_session_id, true); 707 708 // Encode as URL-safe base64 709 return rtrim(strtr(base64_encode($hash), '+/', '-_'), '='); 710 } 711 712 713 }