class-wc-gateway-gnutaler.php (40379B)
1 <?php 2 /** 3 * Plugin to add support for the GNU Taler payment system to WooCommerce. 4 * 5 * @package GNUTalerPayment 6 */ 7 8 /** 9 * Plugin Name: GNU Taler Payment for WooCommerce 10 * Plugin URI: https://git.taler.net/gnu-taler-payment-for-woocommerce 11 * Description: This plugin enables payments via the GNU Taler payment system 12 * Version: 1.1.0 13 * Author: Dominique Hofmann, Jan StrĂ¼bin, Christian Grothoff 14 * Author URI: https://taler.net/ 15 * License: GNU General Public License v3.0 16 * License URI: http://www.gnu.org/licenses/gpl-3.0.html 17 * Requires Plugins: woocommerce 18 * WC requires at least: 9.6 19 * WC tested up to: 10.1 20 * Text Domain: gnutaler 21 **/ 22 23 /* 24 This program is free software: you can redistribute it and/or modify 25 it under the terms of the GNU General Public License as published by 26 the Free Software Foundation, either version 3 of the License, or 27 (at your option) any later version. 28 29 This program is distributed in the hope that it will be useful, 30 but WITHOUT ANY WARRANTY; without even the implied warranty of 31 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 32 GNU General Public License for more details. 33 34 You should have received a copy of the GNU General Public License 35 along with this program. If not, see <https://www.gnu.org/licenses/>. 36 */ 37 38 /** 39 * Which version of the Taler merchant protocol is implemented 40 * by this implementation? Used to determine compatibility. 41 */ 42 define( 'GNU_TALER_MERCHANT_PROTOCOL_CURRENT', 21 ); 43 44 /** 45 * How many merchant protocol versions are we backwards compatible with? 46 */ 47 define( 'GNU_TALER_MERCHANT_PROTOCOL_AGE', 5 ); 48 49 50 51 /** 52 * GNU Taler Payment Gateway class. 53 * 54 * Handles the payments from the Woocommerce Webshop and sends the transactions to the GNU Taler Backend and the GNU Taler Wallet. 55 */ 56 class WC_Gateway_Gnutaler extends WC_Payment_Gateway { 57 58 /** 59 * Cached handle to logging class. 60 * 61 * @var Plugin loggger. 62 */ 63 private static $log = false; 64 65 /** 66 * True if logging is enabled in our configuration. 67 * 68 * @var Is logging enabled? 69 */ 70 private static $log_enabled = false; 71 72 /** 73 * Base URL of the Taler merchant backend. 74 * 75 * @var string 76 */ 77 private $gnu_taler_backend_url; 78 79 /** 80 * Unique id for the gateway. 81 * 82 * @var string 83 */ 84 public $id = 'gnutaler'; 85 86 /** 87 * FIXME: what does this do? 88 * 89 * @var boolean 90 */ 91 public $enable_for_virtual = true; 92 93 /** 94 * Instructions for the user. 95 * 96 * @var string 97 */ 98 protected $instructions; 99 100 /** 101 * Whether the gateway is visible for non-admin users. 102 * 103 * @var boolean 104 */ 105 protected $hide_for_non_admin_users; 106 107 /** 108 * True if debugging is enabled. 109 * 110 * @var boolean 111 */ 112 public $debug; 113 114 /** 115 * Class constructor 116 */ 117 public function __construct() { 118 $this->icon = plugins_url( '/../assets/images/taler.png', __FILE__ ); 119 120 // We cannot use custom fields to show the QR code / do the wallet integration as WC doesn't give us the order_id at that time. Bummer. 121 $this->has_fields = false; 122 123 // Setup logging. 124 $this->debug = 'yes' === $this->get_option( 'debug', 'no' ); 125 self::$log_enabled = $this->debug; 126 127 // This gateway can support refunds, saved payment methods. 128 $this->supports = array( 129 'products', 130 'refunds', 131 ); 132 133 $this->method_title = _x( 'GNU Taler', 'GNU Taler payment method', 'woocommerce-gateway-gnutaler' ); 134 $this->method_description = __( 'This plugin enables payments via the GNU Taler payment system', 'woocommerce-gateway-gnutaler' ); 135 $this->init_form_fields(); 136 $this->init_settings(); 137 138 $this->title = $this->get_option( 'title' ); 139 $this->description = $this->get_option( 'description' ); 140 $this->instructions = $this->get_option( 'instructions' ); 141 $this->hide_for_non_admin_users = $this->get_option( 'hide_for_non_admin_users' ); 142 143 $this->enabled = $this->get_option( 'enabled' ); 144 $this->gnu_taler_backend_url = $this->get_option( 'gnu_taler_backend_url' ); 145 // Remove trailing '/', we add one always ourselves... 146 if ( substr( $this->gnu_taler_backend_url, -1 ) === '/' ) { 147 $this->gnu_taler_backend_url = substr( $this->gnu_taler_backend_url, 0, -1 ); 148 } 149 150 // Make transaction ID a link. We use the public version 151 // here, as a user clicking on the link could not supply 152 // the authorization header. 153 // See also: https://woocommerce.wordpress.com/2014/08/05/wc-2-2-payment-gateways-adding-refund-support-and-transaction-ids/. 154 $this->view_transaction_url = $this->gnu_taler_backend_url . '/orders/%s'; 155 156 // Register handler for the fulfillment URL. 157 add_action( 158 'woocommerce_api_' . strtolower( get_class( $this ) ), 159 array( &$this, 'fulfillment_url_handler' ) 160 ); 161 162 // This action hook saves the settings. 163 add_action( 164 'woocommerce_update_options_payment_gateways_' . $this->id, 165 array( $this, 'process_admin_options' ) 166 ); 167 168 // Modify WC canonical refund e-mail notifications to add link to order status page. 169 // (according to https://www.businessbloomer.com/woocommerce-add-extra-content-order-email/). 170 add_action( 171 'woocommerce_email_before_order_table', 172 array( $this, 'add_content_refund_email' ), 173 20, 174 4 175 ); 176 } 177 178 /** 179 * Check if this payment method is actually available. 180 * 181 * @return boolean 182 */ 183 public function is_available() { 184 if ( ! WC()->session || ! WC()->cart ) { 185 $this->warning( 'WC session or cart not available' ); 186 return false; 187 } 188 $res = ( 'yes' === $this->enabled ); 189 // Deactivate payment method if currency does not match! 190 if ( $res && ( ! $this->verify_backend_url( $this->gnu_taler_backend_url, get_woocommerce_currency() ) ) ) { 191 $this->warning( 'Backend URL verification failed or currency mismatch between backend and shop!' ); 192 $res = false; 193 } 194 $this->debug( 195 $res 196 ? __( 'Returning payment method is available', 'woocommerce-gateway-gnutaler' ) 197 : __( 'Returning payment method is unavailable', 'woocommerce-gateway-gnutaler' ) 198 ); 199 $context = ''; 200 if ( is_admin() ) { 201 $context .= 'admin '; 202 } 203 if ( is_checkout() ) { 204 $context .= 'checkout '; 205 } 206 if ( WC()->cart && WC()->cart->is_empty() ) { 207 $context .= 'empty-cart '; 208 } 209 if ( WC()->cart ) { 210 $context .= 'total-' . WC()->cart->get_total( 'edit' ) . ' '; 211 } 212 $this->debug( $context . ' returning ' . $res ); 213 214 return $res; 215 } 216 217 218 /** 219 * Initialise Gateway Settings Form Fields. 220 */ 221 public function init_form_fields() { 222 $this->form_fields = array( 223 'enabled' => array( 224 'title' => __( 'Enable/Disable', 'woocommerce-gateway-gnutaler' ), 225 'label' => __( 'Enable GNU Taler Gateway', 'woocommerce-gateway-gnutaler' ), 226 'type' => 'checkbox', 227 'description' => '', 228 'default' => 'no', 229 ), 230 'hide_for_non_admin_users' => array( 231 'type' => 'checkbox', 232 'label' => __( 'Hide at checkout for non-admin users', 'woocommerce-gateway-gnutaler' ), 233 'default' => 'no', 234 ), 235 'title' => array( 236 'title' => __( 'Title', 'woocommerce-gateway-gnutaler' ), 237 'type' => 'text', 238 'description' => __( 'This is what the customer will see when choosing payment methods.', 'woocommerce-gateway-gnutaler' ), 239 'default' => 'GNU Taler', 240 'desc_tip' => true, 241 ), 242 'description' => array( 243 'title' => __( 'Description', 'woocommerce-gateway-gnutaler' ), 244 'type' => 'textarea', 245 'description' => __( 'Payment method description which the customer sees during checkout.', 'woocommerce-gateway-gnutaler' ), 246 'default' => __( 'Pay digitally with GNU Taler', 'woocommerce-gateway-gnutaler' ), 247 'desc_tip' => true, 248 ), 249 'instructions' => array( 250 'title' => __( 'Instructions', 'woocommerce-gateway-gnutaler' ), 251 'type' => 'textarea', 252 'description' => __( 'Instructions that will be added to the thank you page.', 'woocommerce-gateway-gnutaler' ), 253 'default' => __( 'Thank you for paying with GNU Taler', 'woocommerce-gateway-gnutaler' ), 254 'desc_tip' => true, 255 ), 256 'gnu_taler_backend_url' => array( 257 'title' => __( 'Taler backend URL', 'woocommerce-gateway-gnutaler' ), 258 'type' => 'text', 259 'description' => __( 'Set the URL of the Taler backend. (Example: https://backend.demo.taler.net/)', 'woocommerce-gateway-gnutaler' ), 260 'default' => 'https://backend.demo.taler.net/instances/sandbox/', 261 ), 262 'GNU_Taler_Backend_API_Key' => array( 263 'title' => __( 'Taler Backend API Key', 'woocommerce-gateway-gnutaler' ), 264 'type' => 'text', 265 'description' => __( 'Enter your API key to authenticate with the Taler backend. Will be sent as a "Bearer" token using the HTTP "Authorization" header. Must be prefixed with "secret-token:" (RFC 8959). This is not your password but a token to be obtained via the /token endpoint from the REST service.', 'woocommerce-gateway-gnutaler' ), 266 'default' => '', 267 ), 268 'Order_text' => array( 269 'title' => __( 'Summary Text of the Order', 'woocommerce-gateway-gnutaler' ), 270 'type' => 'text', 271 'description' => __( 'Set the text the customer will see when confirming payment. #%%s will be substituted with the order number. (Example: MyShop #%%s)', 'woocommerce-gateway-gnutaler' ), 272 'default' => 'WooTalerShop #%s', 273 ), 274 'GNU_Taler_refund_delay' => array( 275 'title' => __( 'How long should refunds be possible', 'woocommerce-gateway-gnutaler' ), 276 'type' => 'number', 277 'description' => __( 'Set the number of days a customer has to request a refund', 'woocommerce-gateway-gnutaler' ), 278 'default' => '14', 279 ), 280 'debug' => array( 281 'title' => __( 'Debug Log', 'woocommerce' ), 282 'label' => __( 'Enable logging', 'woocommerce' ), 283 'description' => sprintf( 284 /* translators: placeholder will be replaced with the path to the log file */ 285 __( 'Log GNU Taler events inside %s.', 'woocommerce-gateway-gnutaler' ), 286 '<code>' . WC_Log_Handler_File::get_log_file_path( 'gnutaler' ) . '</code>' 287 ), 288 'type' => 'checkbox', 289 'default' => 'no', 290 ), 291 ); 292 } 293 294 /** 295 * Called when WC sends out the e-mail notification for refunds. 296 * Adds a Taler-specific notice for where to click to obtain 297 * the refund. 298 * 299 * @param WC_Order $wc_order The order. 300 * @param bool $sent_to_admin Not well documented by WooCommerce. 301 * @param string $plain_text The plain text of the email. 302 * @param string $email Target email address. 303 */ 304 public function add_content_refund_email( $wc_order, $sent_to_admin, $plain_text, $email ) { 305 if ( 'customer_refunded_order' === $email->id ) { 306 $backend_url = $this->gnu_taler_backend_url; 307 $wc_order_id = $wc_order->get_order_key() . '-' . $wc_order->get_order_number(); 308 $refund_url = $wc_order->get_meta( 'GNU_TALER_REFUND_URL' ); 309 printf( 310 /* translators: placeholder will be replaced with the refund URL */ 311 esc_html( __( 'Refund granted. Visit <a href="%1$s">%1$s</a> to obtain the refund.', 'woocommerce-gateway-gnutaler' ) ), 312 esc_url( $refund_url ) 313 ); 314 } 315 } 316 317 /** 318 * Processes and saves options. 319 * If there is an error thrown, will continue to save and validate fields, but 320 * will leave the erroring field out. 321 * 322 * @return bool was anything saved? 323 */ 324 public function process_admin_options() { 325 $saved = parent::process_admin_options(); 326 327 // Maybe clear logs. 328 if ( 'yes' !== $this->get_option( 'debug', 'no' ) ) { 329 if ( empty( self::$log ) ) { 330 self::$log = wc_get_logger(); 331 } 332 self::$log->clear( 'gnutaler' ); 333 } 334 335 return $saved; 336 } 337 338 /** 339 * Required function to add the fields we want to show in the 340 * payment method selection dialog. We show none. 341 */ 342 public function payment_fields() { 343 $this->debug( __( 'payment_fields() called for gnutaler', 'woocommerce-gateway-gnutaler' ) ); 344 // We do not have any. 345 } 346 347 /** 348 * Callback is called, when the user goes to the fulfillment URL. 349 * 350 * We check that the payment actually was made, and update WC accordingly. 351 * If the order ID is unknown and/or the payment did not succeed, we 352 * redirect to the home page and/or the user's order page (for logged in users). 353 */ 354 public function fulfillment_url_handler(): void { 355 global $woocommerce; 356 357 // We intentionally do NOT verify the nonce here, as this page 358 // should work even if the deep link is shared with other users 359 // or even non-users. 360 // phpcs:disable WordPress.Security.NonceVerification 361 if ( ! isset( $_GET['order_id'] ) ) { 362 $this->debug( __( "Lacking 'order_id', forwarding user to neutral page", 'woocommerce-gateway-gnutaler' ) ); 363 if ( is_user_logged_in() ) { 364 wp_safe_redirect( get_home_url() . wc_get_page_permalink( 'myaccount' ) ); 365 } else { 366 wp_safe_redirect( get_home_url() . wc_get_page_permalink( 'shop' ) ); 367 } 368 exit; 369 } 370 371 // Gets the order id from the fulfillment url. 372 $taler_order_id = sanitize_text_field( wp_unslash( $_GET['order_id'] ) ); 373 // phpcs:enable 374 $order_id_array = explode( '-', $taler_order_id ); 375 $order_id_name = $order_id_array[0]; 376 $order_id = $order_id_array[1]; 377 $wc_order = wc_get_order( $order_id ); 378 $backend_url = $this->gnu_taler_backend_url; 379 380 $payment_confirmation = $this->call_api( 381 'GET', 382 $backend_url . '/private/orders/' . $taler_order_id, 383 false 384 ); 385 $payment_body = $payment_confirmation['message']; 386 $payment_http_status = $payment_confirmation['http_code']; 387 388 switch ( $payment_http_status ) { 389 case 200: 390 // Here we check what kind of http code came back from the backend. 391 $merchant_order_status_response = json_decode( 392 $payment_body, 393 $assoc = true 394 ); 395 if ( ! $merchant_order_status_response ) { 396 wc_add_notice( 397 __( 'Payment error:', 'woocommerce-gateway-gnutaler' ) . 398 __( 'backend did not respond', 'woocommerce-gateway-gnutaler' ) 399 ); 400 $this->notice( __( 'Payment failed: no reply from Taler backend', 'woocommerce-gateway-gnutaler' ) ); 401 wp_safe_redirect( $this->get_return_url( $order_id ) ); 402 exit; 403 } 404 if ( 'paid' === $merchant_order_status_response['order_status'] ) { 405 $this->notice( __( 'Payment succeeded and the user was forwarded to the order confirmed page', 'woocommerce-gateway-gnutaler' ) ); 406 // Completes the order, storing a transaction ID. 407 $wc_order->payment_complete( $taler_order_id ); 408 // Empties the shopping cart. 409 WC()->cart->empty_cart(); 410 } else { 411 wc_add_notice( 412 __( 'Payment error:', 'woocommerce-gateway-gnutaler' ) . 413 __( 'backend did not confirm payment', 'woocommerce-gateway-gnutaler' ) 414 ); 415 $this->notice( __( 'Backend did not confirm payment', 'woocommerce-gateway-gnutaler' ) ); 416 } 417 wp_safe_redirect( $this->get_return_url( $wc_order ) ); 418 exit; 419 default: 420 $this->error( 421 __( 'An error occurred during the second request to the GNU Taler backend: ', 'woocommerce-gateway-gnutaler' ) 422 . $payment_http_status . ' - ' . $payment_body 423 ); 424 wc_add_notice( __( 'Payment error:', 'woocommerce-gateway-gnutaler' ) . $payment_http_status . ' - ' . $payment_body ); 425 wp_safe_redirect( $this->get_return_url( $order_id ) ); 426 break; 427 } 428 $cart_url = $woocommerce->cart->wc_get_cart_url(); 429 if ( is_set( $cart_url ) ) { 430 wp_safe_redirect( get_home_url() . $cart_url ); 431 } else { 432 wp_safe_redirect( wc_get_page_permalink( 'shop' ) ); 433 } 434 exit; 435 } 436 437 /** 438 * Sends a request to a url via HTTP. 439 * 440 * Sends a request to a GNU Taler Backend over HTTP and returns the result. 441 * The request can be sent as POST or GET. PATCH is not supported. 442 * 443 * @param string $method POST or GET supported only. Thanks WordPress. 444 * @param string $url URL for the request to make to the GNU Taler Backend. 445 * @param string $body The content of the request (for POST). 446 * 447 * @return array The return array will either have the successful return value or a detailed error message. 448 */ 449 private function call_api( $method, $url, $body ): array { 450 $apikey = $this->get_option( 'GNU_Taler_Backend_API_Key' ); 451 $args = array( 452 'timeout' => 30, // In seconds. 453 'redirection' => 2, // How often. 454 'httpversion' => '1.1', // Taler will support. 455 'user-agent' => '', // Minimize information leakage. 456 'blocking' => true, // We do nothing without it. 457 'headers' => array( 458 'Authorization' => 'Bearer ' . $apikey, 459 ), 460 'decompress' => true, 461 'limit_response_size' => 1024 * 1024, // More than enough. 462 ); 463 if ( $body ) { 464 $args['body'] = wp_json_encode( $body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, 16 ); 465 $args['headers']['Content-type'] = 'application/json'; 466 $args['compress'] = true; 467 } 468 $this->debug( 'Issuing HTTP ' . $method . ' request to ' . $url . ' with options ' . wp_json_encode( $args ) . ' and body ' . $body ); 469 470 switch ( $method ) { 471 case 'POST': 472 $response = wp_remote_post( $url, $args ); 473 break; 474 case 'GET': 475 $response = wp_remote_get( $url, $args ); 476 break; 477 default: 478 $this->debug( 'HTTP method ' . $method . ' not supported' ); 479 return null; 480 } 481 if ( is_wp_error( $response ) ) { 482 $error_code = $response->get_error_code(); 483 $error_data = $response->get_error_data( $error_code ); 484 $this->warning( 485 sprintf( 486 /* translators: first placeholder is the error code, second the error data */ 487 __( 'HTTP failure %1$s with data %2$s', 'woocommerce-gateway-gnutaler' ), 488 $error_code, 489 $error_data 490 ) 491 ); 492 493 return array( 494 'http_code' => 0, 495 'message' => $error_code, 496 ); 497 } 498 $http_code = wp_remote_retrieve_response_code( $response ); 499 $body = wp_remote_retrieve_body( $response ); 500 $this->debug( 501 sprintf( 502 /* translators: first placeholder is the HTTP status code, second the body of the HTTP reply */ 503 __( 'HTTP status %1$s with response body %2$s', 'woocommerce-gateway-gnutaler' ), 504 $http_code, 505 $body 506 ) 507 ); 508 return array( 509 'http_code' => $http_code, 510 'message' => $body, 511 ); 512 } 513 514 /** 515 * Checks if the configuration is valid. Used by WC to redirect 516 * the admin to the setup dialog if they enable the backend and 517 * it is not properly configured. 518 */ 519 public function needs_setup() { 520 $backend_url = $this->gnu_taler_backend_url; 521 $verify_result = $this->verify_backend_url( 522 $backend_url, 523 null 524 ); 525 526 if ($verify_result) { 527 $this->debug( __( 'Backend is ready', 'woocommerce-gateway-gnutaler' ) ); 528 } else { 529 $this->warning( __( 'GNU Taler merchant backend is not setup correctly', 'woocommerce-gateway-gnutaler' ) ); 530 } 531 532 return ! $verify_result; 533 } 534 535 /** 536 * Verifying if the url to the backend given in the plugin options is valid or not. 537 * 538 * @param string $url URL to the backend. 539 * @param string $ecurrency expected currency of the order, null to match any. 540 * 541 * @return bool - Returns if valid or not. 542 */ 543 private function verify_backend_url( $url, $ecurrency ): bool { 544 $this->debug( 'Verifying backend URL ' . $url ); 545 546 $config = $this->call_api( 'GET', $url . '/config', false ); 547 $config_http_status = $config['http_code']; 548 $config_body = $config['message']; 549 switch ( $config_http_status ) { 550 case 200: 551 $info = json_decode( $config_body, $assoc = true ); 552 if ( ! $info ) { 553 $this->error( 554 sprintf( 555 /* translators: placeholder will be replaced with the URL of the backend */ 556 __( '/config response of backend at %s did not decode as JSON', 'woocommerce-gateway-gnutaler' ), 557 $url 558 ) 559 ); 560 return false; 561 } 562 $version = $info['version']; 563 if ( ! $version ) { 564 $this->error( 565 sprintf( 566 /* translators: placeholder will be replaced with the URL of the backend */ 567 __( "No 'version' field in /config reply from Taler backend at %s", 'woocommerce-gateway-gnutaler' ), 568 $url 569 ) 570 ); 571 return false; 572 } 573 $ver = explode( ':', $version, 3 ); 574 $current = $ver[0]; 575 $revision = $ver[1]; 576 $age = $ver[2]; 577 if ( ( ! is_numeric( $current ) ) 578 || ( ! is_numeric( $revision ) ) 579 || ( ! is_numeric( $age ) 580 || ( $age > $current ) ) 581 ) { 582 $this->error( 583 sprintf( 584 /* translators: placeholder will be replaced with the (malformed) version number */ 585 __( "/config response at backend malformed: '%s' is not a valid version", 'woocommerce-gateway-gnutaler' ), 586 $version 587 ) 588 ); 589 return false; 590 } 591 if ( GNU_TALER_MERCHANT_PROTOCOL_CURRENT < $current - $age ) { 592 // Our implementation is too old! 593 $this->error( 594 sprintf( 595 /* translators: placeholder will be replaced with the version number */ 596 __( 'Backend protocol version %s is too new: please update the GNU Taler plugin', 'woocommerce-gateway-gnutaler' ), 597 $version 598 ) 599 ); 600 return false; 601 } 602 if ( GNU_TALER_MERCHANT_PROTOCOL_CURRENT - GNU_TALER_MERCHANT_PROTOCOL_AGE > $current ) { 603 // Merchant implementation is too old! 604 $this->error( 605 sprintf( 606 /* translators: placeholder will be replaced with the version number */ 607 __( 'Backend protocol version %s unsupported: please update the backend', 'woocommerce-gateway-gnutaler' ), 608 $version 609 ) 610 ); 611 return false; 612 } 613 614 $currency_found = is_null( $ecurrency ); 615 if (! $currency_found) { 616 $exchanges = $info['exchanges']; 617 foreach ($exchanges as $exchange) { 618 $currency = $info['currency']; 619 if ( ! is_null( $currency ) && 620 ( 0 === strcasecmp( $currency, $ecurrency ) ) ) { 621 $currency_found = true; 622 break; 623 } 624 } 625 } 626 if ( ! $currency_found ) { 627 $this->error( 628 sprintf( 629 /* translators: first placeholder is the Taler backend currency, second the expected currency from WooCommerce */ 630 __( 'Backend currency %1$s does not match order currency %2$s', 'woocommerce-gateway-gnutaler' ), 631 $currency, 632 $ecurrency 633 ) 634 ); 635 return false; 636 } 637 $this->debug( 638 sprintf( 639 /* translators: placeholder will be replaced with the URL of the backend */ 640 __( '/config check for Taler backend at %s succeeded', 'woocommerce-gateway-gnutaler' ), 641 $url 642 ) 643 ); 644 return true; 645 default: 646 $this->error( 647 sprintf( 648 /* translators: placeholder will be replaced with the HTTP status code returned by the backend */ 649 __( 'Backend failed /config request with unexpected HTTP status %s', 'woocommerce-gateway-gnutaler' ), 650 $config_http_status 651 ) 652 ); 653 return false; 654 } 655 } 656 657 /** 658 * Processes the payment after the checkout 659 * 660 * If the payment process finished successfully the user is being redirected to its GNU Taler Wallet. 661 * If an error occurs it returns void and throws an error. 662 * 663 * @param string $order_id ID of the order to get the Order from the WooCommerce Webshop. 664 * 665 * @return array|void - Array with result => success and redirection url otherwise it returns void. 666 */ 667 public function process_payment( $order_id ) { 668 // We need the order ID to get any order detailes. 669 $wc_order = wc_get_order( $order_id ); 670 671 // Gets the url of the backend from the WooCommerce Settings. 672 $backend_url = $this->gnu_taler_backend_url; 673 674 // Log entry that the customer started the payment process. 675 $this->info( __( 'User started the payment process with GNU Taler.', 'woocommerce-gateway-gnutaler' ) ); 676 677 if ( ! $this->verify_backend_url( $backend_url, $wc_order->get_currency() ) ) { 678 wc_add_notice( __( 'Something went wrong please contact the system administrator of the webshop and send the following error: GNU Taler backend URL invalid', 'woocommerce-gateway-gnutaler' ), 'error' ); 679 $this->error( __( 'Checkout process failed: Invalid backend url.', 'woocommerce-gateway-gnutaler' ) ); 680 return; 681 } 682 $order_json = $this->convert_to_checkout_json( $order_id ); 683 684 $this->info( __( 'Sending POST /private/orders request send to Taler backend', 'woocommerce-gateway-gnutaler' ) ); 685 $order_confirmation = $this->call_api( 686 'POST', 687 $backend_url . '/private/orders', 688 $order_json 689 ); 690 $order_body = $order_confirmation['message']; 691 $order_http_status = $order_confirmation['http_code']; 692 switch ( $order_http_status ) { 693 case 200: 694 $post_order_response = json_decode( $order_body, $assoc = true ); 695 if ( ! $post_order_response ) { 696 $this->error( 697 __( 'POST /private/orders request to Taler backend returned 200 OK, but not a JSON body: ', 'woocommerce-gateway-gnutaler' ) 698 . $order_body 699 ); 700 wc_add_notice( __( 'Malformed response from Taler backend. Please contact the system administrator.' ) ); 701 $wc_order->set_status( 'cancelled' ); 702 return; 703 } 704 $taler_order_id = $post_order_response ['order_id']; 705 $taler_order_token = $post_order_response ['token']; 706 if ( ! $taler_order_id ) { 707 $this->error( 708 __( 'Response to POST /private/orders request to Taler backend lacks order_id field: ', 'woocommerce-gateway-gnutaler' ) 709 . $order_body 710 ); 711 wc_add_notice( __( 'Malformed response from Taler backend. Please contact the system administrator.', 'woocommerce-gateway-gnutaler' ) ); 712 $wc_order->set_status( 'cancelled' ); 713 return; 714 } 715 $this->info( __( 'POST /private/orders successful. Redirecting user to Taler Backend.', 'woocommerce-gateway-gnutaler' ) ); 716 return array( 717 'result' => 'success', 718 'redirect' => $backend_url . '/orders/' . $taler_order_id . '?token=' . $taler_order_token, 719 ); 720 case 404: 721 $post_order_error = json_decode( $order_body, $assoc = true ); 722 if ( ! $post_order_error ) { 723 $this->error( 724 __( 'POST /private/orders request to Taler backend returned 404, but not a JSON body: ', 'woocommerce-gateway-gnutaler' ) 725 . $order_body 726 ); 727 wc_add_notice( __( 'Malformed response from Taler backend. Please contact the system administrator.', 'woocommerce-gateway-gnutaler' ) ); 728 $wc_order->set_status( 'cancelled' ); 729 return; 730 } 731 $this->error( 732 __( 'POST /private/orders request to Taler backend failed: ', 'woocommerce-gateway-gnutaler' ) 733 . $post_order_error['code'] . '(' 734 . $order_http_status . '): ' . $order_body 735 ); 736 wc_add_notice( __( 'Taler backend not configured correctly. Please contact the system administrator.', 'woocommerce-gateway-gnutaler' ) ); 737 $wc_order->set_status( 'cancelled' ); 738 return; 739 case 410: 740 // We don't use inventory management via GNU Taler's backend, so this error should never apply. 741 // Handle with 'default' case below. 742 default: 743 $this->error( 744 __( 'POST /private/orders request to Taler backend failed: ', 'woocommerce-gateway-gnutaler' ) 745 . $post_order_error['code'] . '(' 746 . $order_http_status . '): ' 747 . $order_body 748 ); 749 wc_add_notice( __( 'Unexpected problem with the Taler backend. Please contact the system administrator.', 'woocommerce-gateway-gnutaler' ) ); 750 $wc_order->set_status( 'cancelled' ); 751 return; 752 } 753 } 754 755 /** 756 * Converts the order into a JSON format that can be send to the GNU Taler Backend. 757 * 758 * @param string $order_id ID of the order to get the Order from the WooCommerce Webshop. 759 * 760 * @return array - return the JSON Format. 761 */ 762 public function convert_to_checkout_json( $order_id ): array { 763 $wc_order = wc_get_order( $order_id ); 764 $wc_order_total_amount = $wc_order->get_total(); 765 $wc_order_currency = $wc_order->get_currency(); 766 $wc_cart = WC()->cart->get_cart(); 767 $wc_order_id = $wc_order->get_order_key() . '-' . $wc_order->get_order_number(); 768 $wc_order_products_array = $this->mutate_products_to_json_format( $wc_cart, $wc_order_currency ); 769 $refund_delay = $this->get_option( 'GNU_Taler_refund_delay' ); 770 $order_json = array( 771 'order' => array( 772 'amount' => $wc_order_currency . ':' . $wc_order_total_amount, 773 'summary' => sprintf( 774 $this->get_option( 'Order_text' ), 775 $wc_order->get_order_number() 776 ), 777 // NOTE: This interacts with the 'add_action' call 778 // to invoke the 'fulfillment_url_handler' when the 779 // user goes to this URL! 780 'fulfillment_url' => get_home_url() 781 . '/?wc-api=' 782 . strtolower( get_class( $this ) ) 783 . '&order_id=' 784 . $wc_order_id, 785 'order_id' => $wc_order_id, 786 'products' => $wc_order_products_array, 787 'delivery_location' => $this->mutate_shipping_information_to_json_format( $wc_order ), 788 ), 789 ); 790 if ( isset( $refund_delay ) ) { 791 $order_json['refund_delay'] = array( 792 'd_us' => 1000 * 1000 * 60 * 60 * 24 * intval( $refund_delay ), 793 ); 794 } 795 return $order_json; 796 } 797 798 /** 799 * Mutates the products in the cart into a format which can be included in a JSON file. 800 * 801 * @param WC_Cart $wc_cart The content of the WooCommerce Cart. 802 * @param string $wc_order_currency The currency the WooCommerce Webshop uses. 803 * 804 * @return array - Returns an array of products. 805 */ 806 private function mutate_products_to_json_format( $wc_cart, $wc_order_currency ): array { 807 $wc_order_products_array = array(); 808 foreach ( $wc_cart as $product ) { 809 $wc_order_products_array[] = array( 810 'description' => $product['data']->get_title(), 811 'quantity' => $product['quantity'], 812 'price' => $wc_order_currency . ':' . $product['data']->get_price(), 813 'product_id' => strval( $product['data']->get_id() ), 814 ); 815 } 816 return $wc_order_products_array; 817 } 818 819 /** 820 * Processes the refund transaction if requested by the system administrator of the webshop 821 * 822 * If the refund request is finished successfully it returns an refund url, which can be send to the customer to finish the refund transaction. 823 * If an error it will throw a WP_Error message and inform the system administrator. 824 * 825 * @param WC_Order $wc_order The WooCommerce order object we are processing. 826 * 827 * @return array 828 */ 829 private function mutate_shipping_information_to_json_format( $wc_order ): array { 830 $whitechar_encounter = false; 831 $shipping_address_street = ''; 832 $shipping_address_street_nr = ''; 833 834 $store_address = $wc_order->get_shipping_address_1(); 835 if ( is_null( $store_address ) || empty( $store_address ) ) { 836 $store_address = $wc_order->get_billing_address_1(); 837 } 838 $store_address_inverted = strrev( $store_address ); 839 $store_address_array = str_split( $store_address_inverted ); 840 $country = $wc_order->get_shipping_country(); 841 if ( is_null( $country ) || empty( $country ) ) { 842 $country = $wc_order->get_billing_country(); 843 } 844 $state = $wc_order->get_shipping_state(); 845 if ( is_null( $state ) || empty( $state ) ) { 846 $state = $wc_order->get_billing_state(); 847 } 848 $city = $wc_order->get_shipping_city(); 849 if ( is_null( $city ) || empty( $city ) ) { 850 $city = $wc_order->get_billing_city(); 851 } 852 $postcode = $wc_order->get_shipping_postcode(); 853 if ( is_null( $postcode ) || empty( $postcode ) ) { 854 $postcode = $wc_order->get_billing_postcode(); 855 } 856 857 $this->info( 858 sprintf( 859 'Shipping address is %s - %s - %s - %s - %s', 860 $store_address, 861 $wc_order->get_shipping_country(), 862 $wc_order->get_shipping_state(), 863 $wc_order->get_shipping_city(), 864 $wc_order->get_shipping_postcode() 865 ) 866 ); 867 868 $this->info( 869 sprintf( 870 'Billing address is %s - %s - %s - %s - %s', 871 $store_address, 872 $wc_order->get_billing_country(), 873 $wc_order->get_billing_state(), 874 $wc_order->get_billing_city(), 875 $wc_order->get_billing_postcode() 876 ) 877 ); 878 $this->info( 879 sprintf( 880 'Using address is %s - %s - %s - %s - %s', 881 $store_address, 882 $country, 883 $state, 884 $city, 885 $postcode 886 ) 887 ); 888 889 // Split the address into street and street number. 890 foreach ( $store_address_array as $char ) { 891 if ( ! $whitechar_encounter ) { 892 $shipping_address_street .= $char; 893 } elseif ( ctype_space( $char ) ) { 894 $whitechar_encounter = true; 895 } else { 896 $shipping_address_street .= $char; 897 } 898 } 899 $ret = array( 900 'country' => $country, 901 'country_subdivision' => $state, 902 'town' => $city, 903 'post_code' => $postcode, 904 'street' => $shipping_address_street, 905 'building_number' => $shipping_address_street_nr, 906 ); 907 if ( null !== $wc_order->get_shipping_address_2() ) { 908 $address_lines = array( 909 $wc_order->get_shipping_address_1(), 910 $wc_order->get_shipping_address_2(), 911 ); 912 $ret['address_lines'] = $address_lines; 913 } 914 return $ret; 915 } 916 917 /** 918 * Processes the refund transaction if requested by the system administrator of the webshop 919 * 920 * If the refund request is finished successfully it returns an refund url, which can be send to the customer to finish the refund transaction. 921 * If an error it will throw a WP_Error message and inform the system administrator. 922 * 923 * @param string $order_id Order id for logging. 924 * @param string $amount Amount that is requested to be refunded. 925 * @param string $reason Reason for the refund request. 926 * 927 * @return bool|WP_Error - Returns true or throws an WP_Error message in case of error. 928 */ 929 public function process_refund( $order_id, $amount = null, $reason = '' ) { 930 $wc_order = wc_get_order( $order_id ); 931 932 $this->info( 933 sprintf( 934 /* translators: first placeholder is the numeric amount, second the currency, and third the reason for the refund */ 935 __( 'Refund process started with the refunded amount %1$s %2$s and the reason %3$s.' ), 936 $amount, 937 $wc_order->get_currency(), 938 $reason 939 ) 940 ); 941 942 // Gets the url of the backend from the WooCommerce Settings. 943 $backend_url = $this->gnu_taler_backend_url; 944 945 // Get the current status of the order. 946 $wc_order_status = $wc_order->get_status(); 947 948 // Checks if current status is already set as paid. 949 if ( ! ( 'processing' === $wc_order_status 950 || 'on hold' === $wc_order_status 951 || 'completed' === $wc_order_status ) 952 ) { 953 $this->error( __( 'The status of the order does not allow a refund', 'woocommerce-gateway-gnutaler' ) ); 954 return new WP_Error( 'error', __( 'The status of the order does not allow for a refund.', 'woocommerce-gateway-gnutaler' ) ); 955 } 956 957 $refund_request = array( 958 'refund' => $wc_order->get_currency() . ':' . $amount, 959 'reason' => $reason, 960 ); 961 $wc_order_id = $wc_order->get_order_key() . '-' . $wc_order->get_order_number(); 962 $refund_result = $this->call_api( 963 'POST', 964 $backend_url . '/private/orders/' . $wc_order_id . '/refund', 965 $refund_request 966 ); 967 968 $refund_http_status = $refund_result['http_code']; 969 $refund_body = $refund_result['message']; 970 switch ( $refund_http_status ) { 971 case 200: 972 $refund_response = json_decode( $refund_body, $assoc = true ); 973 if ( ! $refund_response ) { 974 $this->error( __( 'Malformed 200 response from Taler backend: not even in JSON', 'woocommerce-gateway-gnutaler' ) ); 975 return new WP_Error( 'error', __( 'Malformed response from Taler backend', 'woocommerce-gateway-gnutaler' ) ); 976 } 977 $refund_uri = $refund_response['taler_refund_uri']; 978 $h_contract = $refund_response['h_contract']; 979 if ( ( ! $refund_uri ) || ( ! $h_contract ) ) { 980 $this->error( __( 'Malformed 200 response from Taler backend: lacks taler_refund_uri', 'woocommerce-gateway-gnutaler' ) ); 981 return new WP_Error( 'error', __( 'Malformed response from Taler backend', 'woocommerce-gateway-gnutaler' ) ); 982 } 983 $refund_url = $backend_url 984 . '/orders/' 985 . $wc_order_id 986 . '?h_contract=' 987 . $h_contract; 988 $wc_order->add_meta_data( 'GNU_TALER_REFUND_URL', $refund_url ); 989 $wc_order->update_status( 'refunded' ); 990 $this->debug( 991 sprintf( 992 /* translators: argument is the Taler refund URI */ 993 994 __( 'Received refund URI %s from Taler backend', 'woocommerce-gateway-gnutaler' ), 995 $refund_uri 996 ) 997 ); 998 $this->notice( 999 sprintf( 1000 /* translators: argument is the Taler refund URL */ 1001 __( 'The user must visit %s to obtain the refund', 'woocommerce-gateway-gnutaler' ), 1002 $refund_url 1003 ) 1004 ); 1005 return true; 1006 case 403: 1007 return new WP_Error( 1008 'error', 1009 __( 'Refunds are disabled for this order. Check the refund_delay option for the Taler payment plugin.', 'woocommerce-gateway-gnutaler' ) 1010 ); 1011 case 404: 1012 $refund_error = json_decode( $refund_body, $assoc = true ); 1013 if ( ! $refund_error ) { 1014 return new WP_Error( 1015 'error', 1016 sprintf( 1017 /* translators: argument is the HTTP status returned by the backend */ 1018 __( 'Unexpected failure %s without Taler error code from Taler backend', 'woocommerce-gateway-gnutaler' ), 1019 $refund_http_status 1020 ) 1021 ); 1022 } 1023 $ec = $refund_error['code']; 1024 switch ( $ec ) { 1025 case 2000: // TALER_EC_INSTANCE_UNKNOWN! 1026 return new WP_Error( 1027 'error', 1028 __( 'Instance unknown reported by Taler backend', 'woocommerce-gateway-gnutaler' ) 1029 ); 1030 case 2601: // TALER_EC_REFUND_ORDER_ID_UNKNOWN! 1031 return new WP_Error( 1032 'error', 1033 __( 'Order unknown reported by Taler backend', 'woocommerce-gateway-gnutaler' ) 1034 ); 1035 default: 1036 return new WP_Error( 1037 'error', 1038 sprintf( 1039 /* translators: placeholder will be replaced with the numeric GNU Taler error code */ 1040 __( 'Unexpected error %s reported by Taler backend', 'woocommerce-gateway-gnutaler' ), 1041 $ec 1042 ) 1043 ); 1044 } 1045 // This line is unreachable. 1046 // Thus, no break is needed! 1047 case 409: 1048 return new WP_Error( 1049 'error', 1050 __( 'Requested refund amount exceeds original payment. This is not allowed!', 'woocommerce-gateway-gnutaler' ) 1051 ); 1052 case 410: 1053 return new WP_Error( 1054 'error', 1055 __( 'Wire transfer already happened. It is too late for a refund with Taler!', 'woocommerce-gateway-gnutaler' ) 1056 ); 1057 default: 1058 $refund_error = json_decode( $refund_body, $assoc = true ); 1059 if ( ! $refund_error ) { 1060 $ec = $refund_error['code']; 1061 } else { 1062 $ec = 0; 1063 } 1064 return new WP_Error( 1065 'error', 1066 sprintf( 1067 /* translators: first placeholder is the HTTP status code, second the numeric GNU Taler error code */ 1068 __( 'Unexpected failure %1$s/%2$s from Taler backend', 'woocommerce-gateway-gnutaler' ), 1069 $refund_http_status, 1070 $ec 1071 ) 1072 ); 1073 } 1074 } 1075 1076 /** 1077 * Log $msg for debugging 1078 * 1079 * @param string $msg message to log. 1080 */ 1081 private function debug( $msg ): void { 1082 $this->log( 'debug', $msg ); 1083 } 1084 1085 /** 1086 * Log $msg as a informational 1087 * 1088 * @param string $msg message to log. 1089 */ 1090 private function info( $msg ): void { 1091 $this->log( 'info', $msg ); 1092 } 1093 1094 /** 1095 * Log $msg as a notice 1096 * 1097 * @param string $msg message to log. 1098 */ 1099 private function notice( $msg ): void { 1100 $this->log( 'notice', $msg ); 1101 } 1102 1103 /** 1104 * Log $msg as a warning. 1105 * 1106 * @param string $msg message to log. 1107 */ 1108 private function warning( $msg ): void { 1109 $this->log( 'warning', $msg ); 1110 } 1111 1112 /** 1113 * Log $msg as an error 1114 * 1115 * @param string $msg message to log. 1116 */ 1117 private function error( $msg ): void { 1118 $this->log( 'error', $msg ); 1119 } 1120 1121 /** 1122 * Log $msg at log $level. 1123 * 1124 * @param string $level log level to use when logging. 1125 * @param string $msg message to log. 1126 */ 1127 private function log( $level, $msg ) { 1128 if ( ! self::$log_enabled ) { 1129 return; 1130 } 1131 if ( function_exists( 'wp_get_current_user()' ) ) { 1132 $user_id = wp_get_current_user(); 1133 if ( ! isset( $user_id ) ) { 1134 $user_id = __( '<user ID not set>', 'woocommerce-gateway-gnutaler' ); 1135 } 1136 } else { 1137 $user_id = 'Guest'; 1138 } 1139 // We intentionally do NOT verify the nonce here, as logging 1140 // should always work. 1141 // phpcs:disable WordPress.Security.NonceVerification 1142 if ( isset( $_GET['order_id'] ) ) { 1143 $order_id = sanitize_text_field( wp_unslash( $_GET['order_id'] ) ); 1144 } else { 1145 $order_id = 'NONE'; 1146 } 1147 // phpcs:enable 1148 if ( empty( self::$log ) ) { 1149 self::$log = wc_get_logger(); 1150 } 1151 self::$log->log( $level, $user_id . '-' . $order_id . ': ' . $msg, array( 'source' => 'gnutaler' ) ); 1152 } 1153 }