gnu-taler-payment-for-woocommerce

WooCommerce plugin to enable payments with GNU Taler
Log | Files | Refs | LICENSE

commit eebb6e17f989329530f92d12cea9d0fbe2428445
parent 84c1b3cca0f54da0c33f25918df6c67c51ba4afa
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sun, 14 Sep 2025 17:33:53 +0200

starting rewrite for 1.1

Diffstat:
A.gitignore | 47+++++++++++++++++++++++++++++++++++++++++++++++
A.nvmrc | 1+
Abin/build_i18n.sh | 28++++++++++++++++++++++++++++
Abin/install_phpcs.sh | 43+++++++++++++++++++++++++++++++++++++++++++
Dclass-wc-gnutaler-gateway.php | 1134-------------------------------------------------------------------------------
Acomposer.json | 16++++++++++++++++
Aincludes/blocks/class-wc-gnutaler-payments-blocks.php | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aincludes/class-wc-gateway-gnutaler.php | 1129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackage.json | 27+++++++++++++++++++++++++++
Aphpcs.xml | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mreadme.txt | 10++++++++--
Aresources/js/frontend/index.js | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msnippets/kudos-currency.php | 2++
Awebpack.config.js | 47+++++++++++++++++++++++++++++++++++++++++++++++
Awoocommerce-gateway-gnutaler.php | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
15 files changed, 1680 insertions(+), 1136 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,47 @@ +**~ + +# Editors +project.xml +project.properties +/nbproject/private/ +.buildpath +.project +.settings* +sftp-config.json +.idea +._* + +# Grunt +/node_modules/ +/deploy/ +package-lock.json + +# Composer +/vendor/ +composer.lock + +# Sass +.sass-cache/ + +# OS X metadata +.DS_Store + +# Windows junk +Thumbs.db + +# ApiGen +/wc-apidocs/ + +# Tests (Unit and E2E) +/tmp +/tests/coverage/ +/tests/_output/* +!/tests/_output/.gitkeep + +# Logs +/logs + +# Build assets. +select2.scss +/assets/ +/languages/ diff --git a/.nvmrc b/.nvmrc @@ -0,0 +1 @@ +v20 diff --git a/bin/build_i18n.sh b/bin/build_i18n.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# Check for required version. +WPCLI_VERSION=`wp cli version | cut -f2 -d' '` +if [ ${WPCLI_VERSION:0:1} -lt "2" -o ${WPCLI_VERSION:0:1} -eq "2" -a ${WPCLI_VERSION:2:1} -lt "1" ]; then + echo WP-CLI version 2.1.0 or greater is required to make JSON translation files + exit +fi + +# HELPERS. +GREEN='\033[0;32m' +GREY='\033[0;38m' +NC='\033[0m' # No Color +UNDERLINE_START='\e[4m' +UNDERLINE_STOP='\e[0m' + +# Substitute JS source references with build references. +for T in `find languages -name "*.pot"` + do + echo -e "\n${GREY}${UNDERLINE_START}Fixing references for: ${T}${UNDERLINE_STOP}${NC}" + sed \ + -e 's/ resources\/js\/frontend\/[^:]*:/ assets\/frontend\/blocks.js:/gp' \ + $T | uniq > $T-build + + rm $T + mv $T-build $T + echo -e "${GREEN}Done${NC}" + done diff --git a/bin/install_phpcs.sh b/bin/install_phpcs.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# This file is in the public domain. +# Installs phpcs + +set -e + +echo "Installing PHP_CodeSniffer..." +composer require --dev "squizlabs/php_codesniffer=*" + +# Install Composer Installer plugin (required for automatic sniff registration) +composer require --dev "dealerdirect/phpcodesniffer-composer-installer:^1.0" + +echo "==> Making sure composer plugin is allowed" +composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true + +# Install WordPress Coding Standards (WPCS) +echo "Installing WordPress Coding Standards..." +composer require --dev "wp-coding-standards/wpcs=*" + +# Install WooCommerce Coding Standards +echo "Installing WooCommerce Coding Standards..." +composer require --dev "woocommerce/woocommerce-sniffs=*" +composer require --dev "phpcsstandards/phpcsutils:*" +composer require --dev "phpcompatibility/phpcompatibility-wp:*" + +# Install PHPCompatibility +echo "Installing PHPCompatibility..." +composer require --dev "phpcompatibility/phpcompatibility-all" + +# Set installed paths for PHPCS +echo "Setting PHPCS installed paths..." +./vendor/bin/phpcs --config-set installed_paths \ +"vendor/wp-coding-standards/wpcs, \ +vendor/woocommerce/woocommerce-sniffs, \ +vendor/phpcompatibility/phpcompatibility-php73" + +echo "==> Running composer install / update" +composer update --with-all-dependencies + +echo "Verifying installed standards..." +./vendor/bin/phpcs -i + +echo "Done! Your standards are installed and ready to use with phpcs.xml." diff --git a/class-wc-gnutaler-gateway.php b/class-wc-gnutaler-gateway.php @@ -1,1134 +0,0 @@ -<?php -/** - * Plugin to add support for the GNU Taler payment system to WooCommerce. - * - * @package GNUTalerPayment - */ - -/** - * Plugin Name: GNU Taler Payment for WooCommerce - * Plugin URI: https://git.taler.net/woocommerce-taler - * Description: This plugin enables payments via the GNU Taler payment system - * Version: 1.1.0 - * Author: Dominique Hofmann, Jan Strübin, Christian Grothoff - * Author URI: https://taler.net/ - * License: GNU General Public License v3.0 - * License URI: http://www.gnu.org/licenses/gpl-3.0.html - * Requires Plugins: woocommerce - * WC requires at least: 9.6 - * WC tested up to: 10.1 - * Text Domain: gnutaler - **/ - -/* - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see <https://www.gnu.org/licenses/>. -*/ - -/** - * Which version of the Taler merchant protocol is implemented - * by this implementation? Used to determine compatibility. - */ -define( 'GNU_TALER_MERCHANT_PROTOCOL_CURRENT', 21 ); - -/** - * How many merchant protocol versions are we backwards compatible with? - */ -define( 'GNU_TALER_MERCHANT_PROTOCOL_AGE', 5 ); - -require_once ABSPATH . 'wp-admin/includes/plugin.php'; - -// Exit if accessed directly. -if ( ! defined( 'ABSPATH' ) ) { - exit(); -} - -/* - * This action hook registers our PHP class as a WooCommerce payment gateway - */ - -/** - * Adds the GNU Taler payment method to the other payment gateways. - * - * @param array $gateways all the payment gateways. - * @return array - */ -function gnutaler_add_gateway_class( $gateways ) { - $gateways[] = 'WC_GNUTaler_Gateway'; - return $gateways; -} - -// Declare that we are compatible with custom order tables -add_action('before_woocommerce_init', function() { - if (class_exists('\Automattic\WooCommerce\Utilities\FeaturesUtil')) { - \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( - 'custom_order_tables', - __FILE__, - true - ); - } -}); - -// Make GNU Taler payment gateway available to WC -add_filter( 'woocommerce_payment_gateways', 'gnutaler_add_gateway_class' ); - -/** - * The class itself, please note that it is inside plugins_loaded action hook - */ -add_action( 'plugins_loaded', 'gnutaler_init_gateway_class' ); - - -/** - * Wrapper for the GNU Taler payment method class. Sets up internationalization. - */ -function gnutaler_init_gateway_class() { - // Setup textdomain for gettext style translations. - $plugin_rel_path = basename( dirname( __FILE__ ) ) . '/languages'; - load_plugin_textdomain( 'gnutaler', false, $plugin_rel_path ); - - // Check if WooCommerce is active, if not then deactivate and show error message. - if ( ! in_array( 'woocommerce/woocommerce.php', apply_filters( 'active_plugins', get_option( 'active_plugins' ) ), true ) ) { - deactivate_plugins( plugin_basename( __FILE__ ) ); - wp_die( - sprintf( - wp_kses( - /* translators: argument is the link to the plugin page */ - __( - '<strong>GNU Taler</strong> requires <strong>WooCommerce</strong> plugin to work. Please activate it or install it from <a href="http://wordpress.org/plugins/woocommerce/" target="_blank">here</a>.<br /><br />Back to the WordPress <a href="%s">Plugins page</a>.', - 'gnutaler' - ), - array( - 'strong' => array(), - 'a' => array( - 'href' => array(), - '_target' => array(), - ), - ) - ), - esc_url( get_admin_url( null, 'plugins.php' ) ) - ) - ); - } - - /** - * GNU Taler Payment Gateway class. - * - * Handles the payments from the Woocommerce Webshop and sends the transactions to the GNU Taler Backend and the GNU Taler Wallet. - */ - class WC_GNUTaler_Gateway extends WC_Payment_Gateway { - - /** - * Cached handle to logging class. - * - * @var Plugin loggger. - */ - private static $log = false; - - /** - * True if logging is enabled in our configuration. - * - * @var Is logging enabled? - */ - private static $log_enabled = false; - - /** - * Unique id for the gateway. - * @var string - */ - public $id = 'gnutaler'; - - /** - * Class constructor - */ - public function __construct() { - - $this->icon = plugins_url( '/assets/images/taler.png', __FILE__ ); - - // 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. - $this->has_fields = false; - - // Setup logging. - $this->debug = 'yes' === $this->get_option( 'debug', 'no' ); - self::$log_enabled = $this->debug; - - // This gateway can support refunds, saved payment methods. - $this->supports = array( - 'products', - 'refunds', - ); - - $this->method_title = _x( 'GNU Taler', 'GNU Taler payment method', 'gnutaler' ); - $this->method_description = __( 'This plugin enables payments via the GNU Taler payment system', 'gnutaler' ); - $this->init_form_fields(); - $this->init_settings(); - - $this->title = $this->get_option( 'title' ); - $this->description = $this->get_option( 'description' ); - $this->instructions = $this->get_option( 'instructions' ); - $this->enable_for_virtual = true; - - $this->enabled = $this->get_option( 'enabled' ); - $this->gnu_taler_backend_url = $this->get_option( 'gnu_taler_backend_url' ); - // Remove trailing '/', we add one always ourselves... - if ( substr( $this->gnu_taler_backend_url, -1 ) === '/' ) { - $this->gnu_taler_backend_url = substr( $this->gnu_taler_backend_url, 0, -1 ); - } - - // Make transaction ID a link. We use the public version - // here, as a user clicking on the link could not supply - // the authorization header. - // See also: https://woocommerce.wordpress.com/2014/08/05/wc-2-2-payment-gateways-adding-refund-support-and-transaction-ids/. - $this->view_transaction_url = $this->gnu_taler_backend_url . '/orders/%s'; - - // Register handler for the fulfillment URL. - add_action( - 'woocommerce_api_' . strtolower( get_class( $this ) ), - array( &$this, 'fulfillment_url_handler' ) - ); - - // This action hook saves the settings. - add_action( - 'woocommerce_update_options_payment_gateways_' . $this->id, - array( $this, 'process_admin_options' ) - ); - - // Modify WC canonical refund e-mail notifications to add link to order status page. - // (according to https://www.businessbloomer.com/woocommerce-add-extra-content-order-email/). - add_action( - 'woocommerce_email_before_order_table', - array( $this, 'add_content_refund_email' ), - 20, - 4 - ); - } - - public function is_available() { - $res = ('yes' === $this->enabled); - $this->debug ( get_woocommerce_currency() ); - $this->debug( $res - ? __( "Returning payment method is available", 'gnutaler' ) - : __( "Returning payment method is unavailable", 'gnutaler' ) - ); - return $res; - } - - - /** - * Initialise Gateway Settings Form Fields. - */ - public function init_form_fields() { - $this->form_fields = array( - 'enabled' => array( - 'title' => __( 'Enable/Disable', 'gnutaler' ), - 'label' => __( 'Enable GNU Taler Gateway', 'gnutaler' ), - 'type' => 'checkbox', - 'description' => '', - 'default' => 'no', - ), - 'title' => array( - 'title' => __( 'Title', 'gnutaler' ), - 'type' => 'text', - 'description' => __( 'This is what the customer will see when choosing payment methods.', 'gnutaler' ), - 'default' => 'GNU Taler', - 'desc_tip' => true, - ), - 'description' => array( - 'title' => __( 'Description', 'gnutaler' ), - 'type' => 'textarea', - 'description' => __( 'Payment method description which the customer sees during checkout.', 'gnutaler' ), - 'default' => __( 'Pay digitally with GNU Taler', 'gnutaler' ), - 'desc_tip' => true, - ), - 'instructions' => array( - 'title' => __( 'Instructions', 'gnutaler' ), - 'type' => 'textarea', - 'description' => __( 'Instructions that will be added to the thank you page.', 'gnutaler' ), - 'default' => __( 'Thank you for paying with GNU Taler', 'gnutaler' ), - 'desc_tip' => true, - ), - 'gnu_taler_backend_url' => array( - 'title' => __( 'Taler backend URL', 'gnutaler' ), - 'type' => 'text', - 'description' => __( 'Set the URL of the Taler backend. (Example: https://backend.demo.taler.net/)', 'gnutaler' ), - 'default' => 'http://backend.demo.taler.net/instances/sandbox/', - ), - 'GNU_Taler_Backend_API_Key' => array( - 'title' => __( 'Taler Backend API Key', 'gnutaler' ), - 'type' => 'text', - '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.', 'gnutaler' ), - 'default' => 'secret-token:sandbox', - ), - 'Order_text' => array( - 'title' => __( 'Summary Text of the Order', 'gnutaler' ), - 'type' => 'text', - 'description' => __( 'Set the text the customer will see when confirming payment. #%%s will be substituted with the order number. (Example: MyShop #%%s)', 'gnutaler' ), - 'default' => 'WooTalerShop #%s', - ), - 'GNU_Taler_refund_delay' => array( - 'title' => __( 'How long should refunds be possible', 'gnutaler' ), - 'type' => 'number', - 'description' => __( 'Set the number of days a customer has to request a refund', 'gnutaler' ), - 'default' => '14', - ), - 'debug' => array( - 'title' => __( 'Debug Log', 'woocommerce' ), - 'label' => __( 'Enable logging', 'woocommerce' ), - 'description' => sprintf( - /* translators: placeholder will be replaced with the path to the log file */ - __( 'Log GNU Taler events inside %s.', 'gnutaler' ), - '<code>' . WC_Log_Handler_File::get_log_file_path( 'gnutaler' ) . '</code>' - ), - 'type' => 'checkbox', - 'default' => 'no', - ), - ); - } - - /** - * Called when WC sends out the e-mail notification for refunds. - * Adds a Taler-specific notice for where to click to obtain - * the refund. - * - * @param WC_Order $wc_order The order. - * @param bool $sent_to_admin Not well documented by WooCommerce. - * @param string $plain_text The plain text of the email. - * @param string $email Target email address. - */ - public function add_content_refund_email( $wc_order, $sent_to_admin, $plain_text, $email ) { - if ( 'customer_refunded_order' === $email->id ) { - $backend_url = $this->gnu_taler_backend_url; - $wc_order_id = $wc_order->get_order_key() . '-' . $wc_order->get_order_number(); - $refund_url = $wc_order->get_meta( 'GNU_TALER_REFUND_URL' ); - echo sprintf( - /* translators: placeholder will be replaced with the refund URL */ - esc_html( __( 'Refund granted. Visit <a href="%1$s">%1$s</a> to obtain the refund.', 'gnutaler' ) ), - esc_url( $refund_url ) - ); - } - } - - /** - * Processes and saves options. - * If there is an error thrown, will continue to save and validate fields, but - * will leave the erroring field out. - * - * @return bool was anything saved? - */ - public function process_admin_options() { - $saved = parent::process_admin_options(); - - // Maybe clear logs. - if ( 'yes' !== $this->get_option( 'debug', 'no' ) ) { - if ( empty( self::$log ) ) { - self::$log = wc_get_logger(); - } - self::$log->clear( 'gnutaler' ); - } - - return $saved; - } - - /** - * Required function to add the fields we want to show in the - * payment method selection dialog. We show none. - */ - public function payment_fields() { - // We do not have any. - } - - /** - * Callback is called, when the user goes to the fulfillment URL. - * - * We check that the payment actually was made, and update WC accordingly. - * If the order ID is unknown and/or the payment did not succeed, we - * redirect to the home page and/or the user's order page (for logged in users). - */ - public function fulfillment_url_handler(): void { - global $woocommerce; - - // We intentionally do NOT verify the nonce here, as this page - // should work even if the deep link is shared with other users - // or even non-users. - // phpcs:disable WordPress.Security.NonceVerification - if ( ! isset( $_GET['order_id'] ) ) { - $this->debug( __( "Lacking 'order_id', forwarding user to neutral page", 'gnutaler' ) ); - if ( is_user_logged_in() ) { - wp_safe_redirect( get_home_url() . wc_get_page_permalink( 'myaccount' ) ); - } else { - wp_safe_redirect( get_home_url() . wc_get_page_permalink( 'shop' ) ); - } - exit; - } - - // Gets the order id from the fulfillment url. - $taler_order_id = sanitize_text_field( wp_unslash( $_GET['order_id'] ) ); - // phpcs:enable - $order_id_array = explode( '-', $taler_order_id ); - $order_id_name = $order_id_array[0]; - $order_id = $order_id_array[1]; - $wc_order = wc_get_order( $order_id ); - $backend_url = $this->gnu_taler_backend_url; - - $payment_confirmation = $this->call_api( - 'GET', - $backend_url . '/private/orders/' . $taler_order_id, - false - ); - $payment_body = $payment_confirmation['message']; - $payment_http_status = $payment_confirmation['http_code']; - - switch ( $payment_http_status ) { - case 200: - // Here we check what kind of http code came back from the backend. - $merchant_order_status_response = json_decode( - $payment_body, - $assoc = true - ); - if ( ! $merchant_order_status_response ) { - wc_add_notice( - __( 'Payment error:', 'gnutaler' ) . - __( 'backend did not respond', 'gnutaler' ) - ); - $this->notice( __( 'Payment failed: no reply from Taler backend', 'gnutaler' ) ); - wp_safe_redirect( $this->get_return_url( $order_id ) ); - exit; - } - if ( 'paid' === $merchant_order_status_response['order_status'] ) { - $this->notice( __( 'Payment succeeded and the user was forwarded to the order confirmed page', 'gnutaler' ) ); - // Completes the order, storing a transaction ID. - $wc_order->payment_complete( $taler_order_id ); - // Empties the shopping cart. - WC()->cart->empty_cart(); - } else { - wc_add_notice( - __( 'Payment error:', 'gnutaler' ) . - __( 'backend did not confirm payment', 'gnutaler' ) - ); - $this->notice( __( 'Backend did not confirm payment', 'gnutaler' ) ); - } - wp_safe_redirect( $this->get_return_url( $wc_order ) ); - exit; - default: - $this->error( - __( 'An error occurred during the second request to the GNU Taler backend: ', 'gnutaler' ) - . $payment_http_status . ' - ' . $payment_body - ); - wc_add_notice( __( 'Payment error:', 'gnutaler' ) . $payment_http_status . ' - ' . $payment_body ); - wp_safe_redirect( $this->get_return_url( $order_id ) ); - break; - } - $cart_url = $woocommerce->cart->wc_get_cart_url(); - if ( is_set( $cart_url ) ) { - wp_safe_redirect( get_home_url() . $cart_url ); - } else { - wp_safe_redirect( wc_get_page_permalink( 'shop' ) ); - } - exit; - } - - /** - * Sends a request to a url via HTTP. - * - * Sends a request to a GNU Taler Backend over HTTP and returns the result. - * The request can be sent as POST or GET. PATCH is not supported. - * - * @param string $method POST or GET supported only. Thanks WordPress. - * @param string $url URL for the request to make to the GNU Taler Backend. - * @param string $body The content of the request (for POST). - * - * @return array The return array will either have the successful return value or a detailed error message. - */ - private function call_api( $method, $url, $body ): array { - $apikey = $this->get_option( 'GNU_Taler_Backend_API_Key' ); - $args = array( - 'timeout' => 30, // In seconds. - 'redirection' => 2, // How often. - 'httpversion' => '1.1', // Taler will support. - 'user-agent' => '', // Minimize information leakage. - 'blocking' => true, // We do nothing without it. - 'headers' => array( - 'Authorization' => 'Bearer ' . $apikey, - ), - 'decompress' => true, - 'limit_response_size' => 1024 * 1024, // More than enough. - ); - if ( $body ) { - $args['body'] = wp_json_encode( $body, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES, 16 ); - $args['headers']['Content-type'] = 'application/json'; - $args['compress'] = true; - } - $this->debug( 'Issuing HTTP ' . $method . ' request to ' . $url . ' with options ' . wp_json_encode( $args ) . ' and body ' . $body ); - - switch ( $method ) { - case 'POST': - $response = wp_remote_post( $url, $args ); - break; - case 'GET': - $response = wp_remote_get( $url, $args ); - break; - default: - $this->debug( 'HTTP method ' . $method . ' not supported' ); - return null; - } - if ( is_wp_error( $response ) ) { - $error_code = $response->get_error_code(); - $error_data = $response->get_error_data( $error_code ); - $this->warning( - sprintf( - /* translators: first placeholder is the error code, second the error data */ - __( 'HTTP failure %1$s with data %2$s', 'gnutaler' ), - $error_code, - $error_data - ) - ); - - return array( - 'http_code' => 0, - 'message' => $error_code, - ); - } - $http_code = wp_remote_retrieve_response_code( $response ); - $body = wp_remote_retrieve_body( $response ); - $this->debug( - sprintf( - /* translators: first placeholder is the HTTP status code, second the body of the HTTP reply */ - __( 'HTTP status %1$s with response body %2$s', 'gnutaler' ), - $http_code, - $body - ) - ); - return array( - 'http_code' => $http_code, - 'message' => $body, - ); - } - - /** - * Checks if the configuration is valid. Used by WC to redirect - * the admin to the setup dialog if they enable the backend and - * it is not properly configured. - */ - public function needs_setup() { - $backend_url = $this->gnu_taler_backend_url; - $verify_result = $this->verify_backend_url( - $backend_url, - null - ); - - $this->debug( $verify_result - ? __( "Backend is ready", 'gnutaler' ) - : __( "Backend is not setup correctly", 'gnutaler' ) ); - - return ! $verify_result; - } - - /** - * Verifying if the url to the backend given in the plugin options is valid or not. - * - * @param string $url URL to the backend. - * @param string $ecurrency expected currency of the order. - * - * @return bool - Returns if valid or not. - */ - private function verify_backend_url( $url, $ecurrency ): bool { - $this->info( "Verifying backend URL " . $url ); - - $config = $this->call_api( 'GET', $url . '/config', false ); - $config_http_status = $config['http_code']; - $config_body = $config['message']; - switch ( $config_http_status ) { - case 200: - $info = json_decode( $config_body, $assoc = true ); - if ( ! $info ) { - $this->error( - sprintf( - /* translators: placeholder will be replaced with the URL of the backend */ - __( '/config response of backend at %s did not decode as JSON', 'gnutaler' ), - $url - ) - ); - return false; - } - $version = $info['version']; - if ( ! $version ) { - $this->error( - sprintf( - /* translators: placeholder will be replaced with the URL of the backend */ - __( "No 'version' field in /config reply from Taler backend at %s", 'gnutaler' ), - $url - ) - ); - return false; - } - $ver = explode( ':', $version, 3 ); - $current = $ver[0]; - $revision = $ver[1]; - $age = $ver[2]; - if ( ( ! is_numeric( $current ) ) - || ( ! is_numeric( $revision ) ) - || ( ! is_numeric( $age ) - || ( $age > $current ) ) - ) { - $this->error( - sprintf( - /* translators: placeholder will be replaced with the (malformed) version number */ - __( "/config response at backend malformed: '%s' is not a valid version", 'gnutaler' ), - $version - ) - ); - return false; - } - if ( GNU_TALER_MERCHANT_PROTOCOL_CURRENT < $current - $age ) { - // Our implementation is too old! - $this->error( - sprintf( - /* translators: placeholder will be replaced with the version number */ - __( 'Backend protocol version %s is too new: please update the GNU Taler plugin', 'gnutaler' ), - $version - ) - ); - return false; - } - if ( GNU_TALER_MERCHANT_PROTOCOL_CURRENT - GNU_TALER_MERCHANT_PROTOCOL_AGE > $current ) { - // Merchant implementation is too old! - $this->error( - sprintf( - /* translators: placeholder will be replaced with the version number */ - __( 'Backend protocol version %s unsupported: please update the backend', 'gnutaler' ), - $version - ) - ); - return false; - } - $currency = $info['currency']; - if ( ( ! is_null( $ecurrency ) ) && - ( 0 !== strcasecmp( $currency, $ecurrency ) ) ) { - $this->error( - sprintf( - /* translators: first placeholder is the Taler backend currency, second the expected currency from WooCommerce */ - __( 'Backend currency %1$s does not match order currency %2$s', 'gnutaler' ), - $currency, - $ecurrency - ) - ); - return false; - } - $this->debug( - sprintf( - /* translators: placeholder will be replaced with the URL of the backend */ - __( '/config check for Taler backend at %s succeeded', 'gnutaler' ), - $url - ) - ); - return true; - default: - $this->error( - sprintf( - /* translators: placeholder will be replaced with the HTTP status code returned by the backend */ - __( 'Backend failed /config request with unexpected HTTP status %s', 'gnutaler' ), - $config_http_status - ) - ); - return false; - } - } - - /** - * Processes the payment after the checkout - * - * If the payment process finished successfully the user is being redirected to its GNU Taler Wallet. - * If an error occurs it returns void and throws an error. - * - * @param string $order_id ID of the order to get the Order from the WooCommerce Webshop. - * - * @return array|void - Array with result => success and redirection url otherwise it returns void. - */ - public function process_payment( $order_id ) { - // We need the order ID to get any order detailes. - $wc_order = wc_get_order( $order_id ); - - // Gets the url of the backend from the WooCommerce Settings. - $backend_url = $this->gnu_taler_backend_url; - - // Log entry that the customer started the payment process. - $this->info( __( 'User started the payment process with GNU Taler.', 'gnutaler' ) ); - - if ( ! $this->verify_backend_url( $backend_url, $wc_order->get_currency() ) ) { - wc_add_notice( __( 'Something went wrong please contact the system administrator of the webshop and send the following error: GNU Taler backend URL invalid', 'gnutaler' ), 'error' ); - $this->error( __( 'Checkout process failed: Invalid backend url.', 'gnutaler' ) ); - return; - } - $order_json = $this->convert_to_checkout_json( $order_id ); - - $this->info( __( 'Sending POST /private/orders request send to Taler backend', 'gnutaler' ) ); - $order_confirmation = $this->call_api( - 'POST', - $backend_url . '/private/orders', - $order_json - ); - $order_body = $order_confirmation['message']; - $order_http_status = $order_confirmation['http_code']; - switch ( $order_http_status ) { - case 200: - $post_order_response = json_decode( $order_body, $assoc = true ); - if ( ! $post_order_response ) { - $this->error( - __( 'POST /private/orders request to Taler backend returned 200 OK, but not a JSON body: ', 'gnutaler' ) - . $order_body - ); - wc_add_notice( __( 'Malformed response from Taler backend. Please contact the system administrator.' ) ); - $wc_order->set_status( 'cancelled' ); - return; - } - $taler_order_id = $post_order_response ['order_id']; - $taler_order_token = $post_order_response ['token']; - if ( ! $taler_order_id ) { - $this->error( - __( 'Response to POST /private/orders request to Taler backend lacks order_id field: ', 'gnutaler' ) - . $order_body - ); - wc_add_notice( __( 'Malformed response from Taler backend. Please contact the system administrator.', 'gnutaler' ) ); - $wc_order->set_status( 'cancelled' ); - return; - } - $this->info( __( 'POST /private/orders successful. Redirecting user to Taler Backend.', 'gnutaler' ) ); - return array( - 'result' => 'success', - 'redirect' => $backend_url . '/orders/' . $taler_order_id . '?token=' . $taler_order_token, - ); - case 404: - $post_order_error = json_decode( $order_body, $assoc = true ); - if ( ! $post_order_error ) { - $this->error( - __( 'POST /private/orders request to Taler backend returned 404, but not a JSON body: ', 'gnutaler' ) - . $order_body - ); - wc_add_notice( __( 'Malformed response from Taler backend. Please contact the system administrator.', 'gnutaler' ) ); - $wc_order->set_status( 'cancelled' ); - return; - } - $this->error( - __( 'POST /private/orders request to Taler backend failed: ', 'gnutaler' ) - . $post_order_error['code'] . '(' - . $order_http_status . '): ' . $order_body - ); - wc_add_notice( __( 'Taler backend not configured correctly. Please contact the system administrator.', 'gnutaler' ) ); - $wc_order->set_status( 'cancelled' ); - return; - case 410: - // We don't use inventory management via GNU Taler's backend, so this error should never apply. - // Handle with 'default' case below. - default: - $this->error( - __( 'POST /private/orders request to Taler backend failed: ', 'gnutaler' ) - . $post_order_error['code'] . '(' - . $order_http_status . '): ' - . $order_body - ); - wc_add_notice( __( 'Unexpected problem with the Taler backend. Please contact the system administrator.', 'gnutaler' ) ); - $wc_order->set_status( 'cancelled' ); - return; - } - } - - /** - * Converts the order into a JSON format that can be send to the GNU Taler Backend. - * - * @param string $order_id ID of the order to get the Order from the WooCommerce Webshop. - * - * @return array - return the JSON Format. - */ - public function convert_to_checkout_json( $order_id ): array { - $wc_order = wc_get_order( $order_id ); - $wc_order_total_amount = $wc_order->get_total(); - $wc_order_currency = $wc_order->get_currency(); - $wc_cart = WC()->cart->get_cart(); - $wc_order_id = $wc_order->get_order_key() . '-' . $wc_order->get_order_number(); - $wc_order_products_array = $this->mutate_products_to_json_format( $wc_cart, $wc_order_currency ); - $refund_delay = $this->get_option( 'GNU_Taler_refund_delay' ); - $order_json = array( - 'order' => array( - 'amount' => $wc_order_currency . ':' . $wc_order_total_amount, - 'summary' => sprintf( - $this->get_option( 'Order_text' ), - $wc_order->get_order_number() - ), - // NOTE: This interacts with the 'add_action' call - // to invoke the 'fulfillment_url_handler' when the - // user goes to this URL! - 'fulfillment_url' => get_home_url() - . '/?wc-api=' - . strtolower( get_class( $this ) ) - . '&order_id=' - . $wc_order_id, - 'order_id' => $wc_order_id, - 'products' => $wc_order_products_array, - 'delivery_location' => $this->mutate_shipping_information_to_json_format( $wc_order ), - ), - ); - if ( isset( $refund_delay ) ) { - $order_json['refund_delay'] = array( - 'd_us' => 1000 * 1000 * 60 * 60 * 24 * intval( $refund_delay ), - ); - } - return $order_json; - } - - /** - * Mutates the products in the cart into a format which can be included in a JSON file. - * - * @param WC_Cart $wc_cart The content of the WooCommerce Cart. - * @param string $wc_order_currency The currency the WooCommerce Webshop uses. - * - * @return array - Returns an array of products. - */ - private function mutate_products_to_json_format( $wc_cart, $wc_order_currency ): array { - $wc_order_products_array = array(); - foreach ( $wc_cart as $product ) { - $wc_order_products_array[] = array( - 'description' => $product['data']->get_title(), - 'quantity' => $product['quantity'], - 'price' => $wc_order_currency . ':' . $product['data']->get_price(), - 'product_id' => strval( $product['data']->get_id() ), - ); - } - return $wc_order_products_array; - } - - /** - * Processes the refund transaction if requested by the system administrator of the webshop - * - * If the refund request is finished successfully it returns an refund url, which can be send to the customer to finish the refund transaction. - * If an error it will throw a WP_Error message and inform the system administrator. - * - * @param WC_Order $wc_order The WooCommerce order object we are processing. - * - * @return array - */ - private function mutate_shipping_information_to_json_format( $wc_order ): array { - $whitechar_encounter = false; - $shipping_address_street = ''; - $shipping_address_street_nr = ''; - - $store_address = $wc_order->get_shipping_address_1( $context = 'view' ); - if ( is_null( $store_address ) || empty( $store_address ) ) - $store_address = $wc_order->get_billing_address_1( $context = 'view' ); - $store_address_inverted = strrev( $store_address ); - $store_address_array = str_split( $store_address_inverted ); - $country = $wc_order->get_shipping_country ($context = 'view'); - if ( is_null( $country ) || empty( $country ) ) - $country = $wc_order->get_billing_country ($context = 'view'); - $state = $wc_order->get_shipping_state ($context = 'view'); - if ( is_null( $state ) || empty( $state ) ) - $state = $wc_order->get_billing_state ($context = 'view'); - $city = $wc_order->get_shipping_city ($context = 'view'); - if ( is_null( $city ) || empty( $city ) ) - $city = $wc_order->get_billing_city ($context = 'view'); - $postcode = $wc_order->get_shipping_postcode ($context = 'view'); - if ( is_null( $postcode ) || empty( $postcode ) ) - $postcode = $wc_order->get_billing_postcode ($context = 'view'); - - $this->info ( - sprintf( - 'Shipping address is %s - %s - %s - %s - %s', - $store_address, - $wc_order->get_shipping_country ($context = 'view'), - $wc_order->get_shipping_state ($context = 'view'), - $wc_order->get_shipping_city ($context = 'view'), - $wc_order->get_shipping_postcode ($context = 'view')) ); - - $this->info ( - sprintf( - 'Billing address is %s - %s - %s - %s - %s', - $store_address, - $wc_order->get_billing_country ($context = 'view'), - $wc_order->get_billing_state ($context = 'view'), - $wc_order->get_billing_city ($context = 'view'), - $wc_order->get_billing_postcode ($context = 'view')) ); - $this->info ( - sprintf( - 'Using address is %s - %s - %s - %s - %s', - $store_address, - $country, - $state, - $city, - $postcode)); - - // Split the address into street and street number. - foreach ( $store_address_array as $char ) { - if ( ! $whitechar_encounter ) { - $shipping_address_street .= $char; - } elseif ( ctype_space( $char ) ) { - $whitechar_encounter = true; - } else { - $shipping_address_street .= $char; - } - } - $ret = array( - 'country' => $country, - 'country_subdivision' => $state, - 'town' => $city, - 'post_code' => $postcode, - 'street' => $shipping_address_street, - 'building_number' => $shipping_address_street_nr, - ); - if ( null !== $wc_order->get_shipping_address_2() ) { - $address_lines = array( - $wc_order->get_shipping_address_1(), - $wc_order->get_shipping_address_2(), - ); - $ret['address_lines'] = $address_lines; - } - return $ret; - } - - /** - * Processes the refund transaction if requested by the system administrator of the webshop - * - * If the refund request is finished successfully it returns an refund url, which can be send to the customer to finish the refund transaction. - * If an error it will throw a WP_Error message and inform the system administrator. - * - * @param string $order_id Order id for logging. - * @param string $amount Amount that is requested to be refunded. - * @param string $reason Reason for the refund request. - * - * @return bool|WP_Error - Returns true or throws an WP_Error message in case of error. - */ - public function process_refund( $order_id, $amount = null, $reason = '' ) { - $wc_order = wc_get_order( $order_id ); - - $this->info( - sprintf( - /* translators: first placeholder is the numeric amount, second the currency, and third the reason for the refund */ - __( 'Refund process started with the refunded amount %1$s %2$s and the reason %3$s.' ), - $amount, - $wc_order->get_currency(), - $reason - ) - ); - - // Gets the url of the backend from the WooCommerce Settings. - $backend_url = $this->gnu_taler_backend_url; - - // Get the current status of the order. - $wc_order_status = $wc_order->get_status(); - - // Checks if current status is already set as paid. - if ( ! ( 'processing' === $wc_order_status - || 'on hold' === $wc_order_status - || 'completed' === $wc_order_status ) - ) { - $this->error( __( 'The status of the order does not allow a refund', 'gnutaler' ) ); - return new WP_Error( 'error', __( 'The status of the order does not allow for a refund.', 'gnutaler' ) ); - } - - $refund_request = array( - 'refund' => $wc_order->get_currency() . ':' . $amount, - 'reason' => $reason, - ); - $wc_order_id = $wc_order->get_order_key() . '-' . $wc_order->get_order_number(); - $refund_result = $this->call_api( - 'POST', - $backend_url . '/private/orders/' . $wc_order_id . '/refund', - $refund_request - ); - - $refund_http_status = $refund_result['http_code']; - $refund_body = $refund_result['message']; - switch ( $refund_http_status ) { - case 200: - $refund_response = json_decode( $refund_body, $assoc = true ); - if ( ! $refund_response ) { - $this->error( __( 'Malformed 200 response from Taler backend: not even in JSON', 'gnutaler' ) ); - return new WP_Error( 'error', __( 'Malformed response from Taler backend', 'gnutaler' ) ); - } - $refund_uri = $refund_response['taler_refund_uri']; - $h_contract = $refund_response['h_contract']; - if ( ( ! $refund_uri ) || ( ! $h_contract ) ) { - $this->error( __( 'Malformed 200 response from Taler backend: lacks taler_refund_uri', 'gnutaler' ) ); - return new WP_Error( 'error', __( 'Malformed response from Taler backend', 'gnutaler' ) ); - } - $refund_url = $backend_url - . '/orders/' - . $wc_order_id - . '?h_contract=' - . $h_contract; - $wc_order->add_meta_data( 'GNU_TALER_REFUND_URL', $refund_url ); - $wc_order->update_status( 'refunded' ); - $this->debug( - sprintf( - /* translators: argument is the Taler refund URI */ - - __( 'Received refund URI %s from Taler backend', 'gnutaler' ), - $refund_uri - ) - ); - $this->notice( - sprintf( - /* translators: argument is the Taler refund URL */ - __( 'The user must visit %s to obtain the refund', 'gnutaler' ), - $refund_url - ) - ); - return true; - case 403: - return new WP_Error( - 'error', - __( 'Refunds are disabled for this order. Check the refund_delay option for the Taler payment plugin.', 'gnutaler' ) - ); - case 404: - $refund_error = json_decode( $refund_body, $assoc = true ); - if ( ! $refund_error ) { - return new WP_Error( - 'error', - sprintf( - /* translators: argument is the HTTP status returned by the backend */ - __( 'Unexpected failure %s without Taler error code from Taler backend', 'gnutaler' ), - $refund_http_status - ) - ); - } - $ec = $refund_error['code']; - switch ( $ec ) { - case 2000: // TALER_EC_INSTANCE_UNKNOWN! - return new WP_Error( - 'error', - __( 'Instance unknown reported by Taler backend', 'gnutaler' ) - ); - case 2601: // TALER_EC_REFUND_ORDER_ID_UNKNOWN! - return new WP_Error( - 'error', - __( 'Order unknown reported by Taler backend', 'gnutaler' ) - ); - default: - return new WP_Error( - 'error', - sprintf( - /* translators: placeholder will be replaced with the numeric GNU Taler error code */ - __( 'Unexpected error %s reported by Taler backend', 'gnutaler' ), - $ec - ) - ); - } - // This line is unreachable. - case 409: - return new WP_Error( - 'error', - __( 'Requested refund amount exceeds original payment. This is not allowed!', 'gnutaler' ) - ); - case 410: - return new WP_Error( - 'error', - __( 'Wire transfer already happened. It is too late for a refund with Taler!', 'gnutaler' ) - ); - default: - $refund_error = json_decode( $refund_body, $assoc = true ); - if ( ! $refund_error ) { - $ec = $refund_error['code']; - } else { - $ec = 0; - } - return new WP_Error( - 'error', - sprintf( - /* translators: first placeholder is the HTTP status code, second the numeric GNU Taler error code */ - __( 'Unexpected failure %1$s/%2$s from Taler backend', 'gnutaler' ), - $refund_http_status, - $ec - ) - ); - } - } - - /** - * Log $msg for debugging - * - * @param string $msg message to log. - */ - private function debug( $msg ) : void { - $this->log( 'debug', $msg ); - } - - /** - * Log $msg as a informational - * - * @param string $msg message to log. - */ - private function info( $msg ) : void { - $this->log( 'info', $msg ); - } - - /** - * Log $msg as a notice - * - * @param string $msg message to log. - */ - private function notice( $msg ) : void { - $this->log( 'notice', $msg ); - } - - /** - * Log $msg as a warning. - * - * @param string $msg message to log. - */ - private function warning( $msg ) : void { - $this->log( 'warning', $msg ); - } - - /** - * Log $msg as an error - * - * @param string $msg message to log. - */ - private function error( $msg ) : void { - $this->log( 'error', $msg ); - } - - /** - * Log $msg at log $level. - * - * @param string $level log level to use when logging. - * @param string $msg message to log. - */ - private function log( $level, $msg ) { - if ( ! self::$log_enabled ) { - return; - } - if ( function_exists( 'wp_get_current_user()' ) ) { - $user_id = wp_get_current_user(); - if ( ! isset( $user_id ) ) { - $user_id = __( '<user ID not set>', 'gnutaler' ); - } - } else { - $user_id = 'Guest'; - } - // We intentionally do NOT verify the nonce here, as logging - // should always work. - // phpcs:disable WordPress.Security.NonceVerification - if ( isset ($_GET['order_id'] ) ) { - $order_id = sanitize_text_field( wp_unslash( $_GET['order_id'] ) ); - } - else - { - $order_id = 'NONE'; - } - // phpcs:enable - if ( empty( self::$log ) ) { - self::$log = wc_get_logger(); - } - self::$log->log( $level, $user_id . '-' . $order_id . ': ' . $msg, array( 'source' => 'gnutaler' ) ); - } - - } -} diff --git a/composer.json b/composer.json @@ -0,0 +1,16 @@ +{ + "require-dev": { + "squizlabs/php_codesniffer": "*", + "wp-coding-standards/wpcs": "*", + "woocommerce/woocommerce-sniffs": "*", + "phpcompatibility/phpcompatibility-all": "^1.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpcsstandards/phpcsutils": "*", + "phpcompatibility/phpcompatibility-wp": "*" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/includes/blocks/class-wc-gnutaler-payments-blocks.php b/includes/blocks/class-wc-gnutaler-payments-blocks.php @@ -0,0 +1,100 @@ +<?php +/** + * GNU Taler payment method integration for WooCommerce Blocks + */ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +use Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType; + +final class WC_Gateway_Gnutaler_Blocks_Support extends AbstractPaymentMethodType { + + /** + * The gateway instance. + * + * @var WC_Gateway_Gnutaler + */ + private $gateway; + + /** + * Payment method id. + */ + protected $name = 'gnutaler'; + + /** + * Initializes the payment method type. + */ + public function initialize() { + $this->settings = get_option('woocommerce_gnutaler_settings', array()); + $gateways = WC()->payment_gateways->payment_gateways(); + $this->gateway = $gateways[ $this->name ]; + } + + /** + * Returns if this payment method should be active. + * + * @return boolean + */ + public function is_active() { + return $this->gateway->is_available(); + } + + /** + * Returns an array of scripts/handles to be registered for this payment method. + * + * @return array + */ + public function get_payment_method_script_handles() { + $script_path = '/assets/js/frontend/blocks.js'; + $script_asset_path = plugin_dir_path(__FILE__) . '../assets/js/frontend/blocks.asset.php'; + $script_asset = file_exists($script_asset_path) + ? require $script_asset_path + : array( + 'dependencies' => array(), + 'version' => '1.0.0', + ); + $script_url = WC_Gnutaler_Payments::plugin_url() . $script_path; + + wp_register_script( + 'wc-gnutaler-payments-blocks', + $script_url, + $script_asset['dependencies'], + $script_asset['version'], + true + ); + + if ( function_exists( 'wp_set_script_translations' ) ) { + wp_set_script_translations( 'wc-gnutaler-payments-blocks', 'woocommerce-gateway-gnutaler', WC_Gnutaler_Payments::plugin_abspath() . 'languages/' ); + } + + return array( 'wc-gnutaler-payments-blocks' ); + } + + /** + * Returns an array of key=>value pairs of data made available to the payment methods script. + */ + public function get_payment_method_data() { + $payment_gateways_class = WC()->payment_gateways(); + $payment_gateways = $payment_gateways_class->payment_gateways(); + $gateway = $payment_gateways['gnutaler']; + + return array( + 'title' => $this->get_setting( 'title' ), + 'description' => $this->get_setting( 'description' ), + 'supports' => array_filter( $this->gateway->supports, array( $this->gateway, 'supports' ) ), + ); + } +} diff --git a/includes/class-wc-gateway-gnutaler.php b/includes/class-wc-gateway-gnutaler.php @@ -0,0 +1,1129 @@ +<?php +/** + * Plugin to add support for the GNU Taler payment system to WooCommerce. + * + * @package GNUTalerPayment + */ + +/** + * Plugin Name: GNU Taler Payment for WooCommerce + * Plugin URI: https://git.taler.net/gnu-taler-payment-for-woocommerce + * Description: This plugin enables payments via the GNU Taler payment system + * Version: 1.1.0 + * Author: Dominique Hofmann, Jan Strübin, Christian Grothoff + * Author URI: https://taler.net/ + * License: GNU General Public License v3.0 + * License URI: http://www.gnu.org/licenses/gpl-3.0.html + * Requires Plugins: woocommerce + * WC requires at least: 9.6 + * WC tested up to: 10.1 + * Text Domain: gnutaler + **/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +/** + * Which version of the Taler merchant protocol is implemented + * by this implementation? Used to determine compatibility. + */ +define( 'GNU_TALER_MERCHANT_PROTOCOL_CURRENT', 21 ); + +/** + * How many merchant protocol versions are we backwards compatible with? + */ +define( 'GNU_TALER_MERCHANT_PROTOCOL_AGE', 5 ); + + + + /** + * GNU Taler Payment Gateway class. + * + * Handles the payments from the Woocommerce Webshop and sends the transactions to the GNU Taler Backend and the GNU Taler Wallet. + */ + class WC_Gateway_Gnutaler extends WC_Payment_Gateway { + + /** + * Cached handle to logging class. + * + * @var Plugin loggger. + */ + private static $log = false; + + /** + * True if logging is enabled in our configuration. + * + * @var Is logging enabled? + */ + private static $log_enabled = false; + + /** + * Base URL of the Taler merchant backend. + * + * @var string + */ + private $gnu_taler_backend_url; + + /** + * Unique id for the gateway. + * + * @var string + */ + public $id = 'gnutaler'; + + /** + * FIXME: what does this do? + */ + public $enable_for_virtual = true; + + /** + * Instructions for the user. + */ + protected $instructions; + + /** + * Whether the gateway is visible for non-admin users. + * + * @var boolean + * + */ + protected $hide_for_non_admin_users; + + /** + * True if debugging is enabled. + */ + public $debug; + + /** + * Class constructor + */ + public function __construct() { + $this->icon = plugins_url( '/assets/images/taler.png', __FILE__ ); + + // 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. + $this->has_fields = false; + + // Setup logging. + $this->debug = 'yes' === $this->get_option( 'debug', 'no' ); + self::$log_enabled = $this->debug; + + // This gateway can support refunds, saved payment methods. + $this->supports = array( + 'products', + 'refunds', + ); + + $this->method_title = _x( 'GNU Taler', 'GNU Taler payment method', 'woocommerce-gateway-gnutaler' ); + $this->method_description = __( 'This plugin enables payments via the GNU Taler payment system', 'woocommerce-gateway-gnutaler' ); + $this->init_form_fields(); + $this->init_settings(); + + $this->title = $this->get_option( 'title' ); + $this->description = $this->get_option( 'description' ); + $this->instructions = $this->get_option( 'instructions' ); + $this->hide_for_non_admin_users = $this->get_option( 'hide_for_non_admin_users' ); + + $this->enabled = $this->get_option( 'enabled' ); + $this->gnu_taler_backend_url = $this->get_option( 'gnu_taler_backend_url' ); + // Remove trailing '/', we add one always ourselves... + if ( substr( $this->gnu_taler_backend_url, -1 ) === '/' ) { + $this->gnu_taler_backend_url = substr( $this->gnu_taler_backend_url, 0, -1 ); + } + + // Make transaction ID a link. We use the public version + // here, as a user clicking on the link could not supply + // the authorization header. + // See also: https://woocommerce.wordpress.com/2014/08/05/wc-2-2-payment-gateways-adding-refund-support-and-transaction-ids/. + $this->view_transaction_url = $this->gnu_taler_backend_url . '/orders/%s'; + + // Register handler for the fulfillment URL. + add_action( + 'woocommerce_api_' . strtolower( get_class( $this ) ), + array( &$this, 'fulfillment_url_handler' ) + ); + + // This action hook saves the settings. + add_action( + 'woocommerce_update_options_payment_gateways_' . $this->id, + array( $this, 'process_admin_options' ) + ); + + // Modify WC canonical refund e-mail notifications to add link to order status page. + // (according to https://www.businessbloomer.com/woocommerce-add-extra-content-order-email/). + add_action( + 'woocommerce_email_before_order_table', + array( $this, 'add_content_refund_email' ), + 20, + 4 + ); + } + + public function is_available() { + if (!WC()->session || !WC()->cart) { + $this->debug('WC session or cart not available'); + return false; + } + // FIXME: tons of debug logic in this function to be removed... + $needs_payment = WC()->cart ? WC()->cart->needs_payment() : false; + $cart_total = WC()->cart ? WC()->cart->get_total('edit') : 0; + + $this->debug('Cart needs payment: ' . ($needs_payment ? 'yes' : 'no')); + $this->debug('Cart total: ' . $cart_total); + + $res = ('yes' === $this->enabled); + // FIXME: deactivate payment method if currency does not match! + if ( $res && (! $this->verify_backend_url($this->gnu_taler_backend_url, get_woocommerce_currency()) ) ) { + $this->debug('Backend URL verification failed or currency mismatch between backend and shop!'); + $res = false; + } + $this->debug( $res + ? __( 'Returning payment method is available', 'woocommerce-gateway-gnutaler' ) + : __( 'Returning payment method is unavailable', 'woocommerce-gateway-gnutaler' ) + ); + $context = 'is_available() called from: ' . $caller . ' - Context: '; + if (is_admin()) { +$context .= 'admin '; + } + if (is_checkout()) { +$context .= 'checkout '; + } + if (WC()->cart && WC()->cart->is_empty()) { +$context .= 'empty-cart '; + } + if (WC()->cart) { +$context .= 'total-' . WC()->cart->get_total('edit') . ' '; + } + $this->debug ( $context . ' returning ' . $res); + + return $res; + } + + + /** + * Initialise Gateway Settings Form Fields. + */ + public function init_form_fields() { + $this->form_fields = array( + 'enabled' => array( + 'title' => __( 'Enable/Disable', 'woocommerce-gateway-gnutaler' ), + 'label' => __( 'Enable GNU Taler Gateway', 'woocommerce-gateway-gnutaler' ), + 'type' => 'checkbox', + 'description' => '', + 'default' => 'no', + ), + 'hide_for_non_admin_users' => array( + 'type' => 'checkbox', + 'label' => __( 'Hide at checkout for non-admin users', 'woocommerce-gateway-gnutaler' ), + 'default' => 'no', + ), + 'title' => array( + 'title' => __( 'Title', 'woocommerce-gateway-gnutaler' ), + 'type' => 'text', + 'description' => __( 'This is what the customer will see when choosing payment methods.', 'woocommerce-gateway-gnutaler' ), + 'default' => 'GNU Taler', + 'desc_tip' => true, + ), + 'description' => array( + 'title' => __( 'Description', 'woocommerce-gateway-gnutaler' ), + 'type' => 'textarea', + 'description' => __( 'Payment method description which the customer sees during checkout.', 'woocommerce-gateway-gnutaler' ), + 'default' => __( 'Pay digitally with GNU Taler', 'woocommerce-gateway-gnutaler' ), + 'desc_tip' => true, + ), + 'instructions' => array( + 'title' => __( 'Instructions', 'woocommerce-gateway-gnutaler' ), + 'type' => 'textarea', + 'description' => __( 'Instructions that will be added to the thank you page.', 'woocommerce-gateway-gnutaler' ), + 'default' => __( 'Thank you for paying with GNU Taler', 'woocommerce-gateway-gnutaler' ), + 'desc_tip' => true, + ), + 'gnu_taler_backend_url' => array( + 'title' => __( 'Taler backend URL', 'woocommerce-gateway-gnutaler' ), + 'type' => 'text', + 'description' => __( 'Set the URL of the Taler backend. (Example: https://backend.demo.taler.net/)', 'woocommerce-gateway-gnutaler' ), + 'default' => 'http://backend.demo.taler.net/instances/sandbox/', + ), + 'GNU_Taler_Backend_API_Key' => array( + 'title' => __( 'Taler Backend API Key', 'woocommerce-gateway-gnutaler' ), + 'type' => 'text', + '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' ), + 'default' => 'secret-token:sandbox', + ), + 'Order_text' => array( + 'title' => __( 'Summary Text of the Order', 'woocommerce-gateway-gnutaler' ), + 'type' => 'text', + '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' ), + 'default' => 'WooTalerShop #%s', + ), + 'GNU_Taler_refund_delay' => array( + 'title' => __( 'How long should refunds be possible', 'woocommerce-gateway-gnutaler' ), + 'type' => 'number', + 'description' => __( 'Set the number of days a customer has to request a refund', 'woocommerce-gateway-gnutaler' ), + 'default' => '14', + ), + 'debug' => array( + 'title' => __( 'Debug Log', 'woocommerce' ), + 'label' => __( 'Enable logging', 'woocommerce' ), + 'description' => sprintf( + /* translators: placeholder will be replaced with the path to the log file */ + __( 'Log GNU Taler events inside %s.', 'woocommerce-gateway-gnutaler' ), + '<code>' . WC_Log_Handler_File::get_log_file_path( 'gnutaler' ) . '</code>' + ), + 'type' => 'checkbox', + 'default' => 'no', + ), + ); + } + + /** + * Called when WC sends out the e-mail notification for refunds. + * Adds a Taler-specific notice for where to click to obtain + * the refund. + * + * @param WC_Order $wc_order The order. + * @param bool $sent_to_admin Not well documented by WooCommerce. + * @param string $plain_text The plain text of the email. + * @param string $email Target email address. + */ + public function add_content_refund_email( $wc_order, $sent_to_admin, $plain_text, $email ) { + if ( 'customer_refunded_order' === $email->id ) { + $backend_url = $this->gnu_taler_backend_url; + $wc_order_id = $wc_order->get_order_key() . '-' . $wc_order->get_order_number(); + $refund_url = $wc_order->get_meta( 'GNU_TALER_REFUND_URL' ); + printf( + /* translators: placeholder will be replaced with the refund URL */ + esc_html( __( 'Refund granted. Visit <a href="%1$s">%1$s</a> to obtain the refund.', 'woocommerce-gateway-gnutaler' ) ), + esc_url( $refund_url ) + ); + } + } + + /** + * Processes and saves options. + * If there is an error thrown, will continue to save and validate fields, but + * will leave the erroring field out. + * + * @return bool was anything saved? + */ + public function process_admin_options() { + $saved = parent::process_admin_options(); + + // Maybe clear logs. + if ( 'yes' !== $this->get_option( 'debug', 'no' ) ) { + if ( empty( self::$log ) ) { + self::$log = wc_get_logger(); + } + self::$log->clear( 'gnutaler' ); + } + + return $saved; + } + + /** + * Required function to add the fields we want to show in the + * payment method selection dialog. We show none. + */ + public function payment_fields() { + $this->debug( __( 'payment_fields() called for gnutaler', 'woocommerce-gateway-gnutaler' ) ); + // We do not have any. + } + + /** + * Callback is called, when the user goes to the fulfillment URL. + * + * We check that the payment actually was made, and update WC accordingly. + * If the order ID is unknown and/or the payment did not succeed, we + * redirect to the home page and/or the user's order page (for logged in users). + */ + public function fulfillment_url_handler(): void { + global $woocommerce; + + // We intentionally do NOT verify the nonce here, as this page + // should work even if the deep link is shared with other users + // or even non-users. + // phpcs:disable WordPress.Security.NonceVerification + if ( ! isset( $_GET['order_id'] ) ) { + $this->debug( __( "Lacking 'order_id', forwarding user to neutral page", 'woocommerce-gateway-gnutaler' ) ); + if ( is_user_logged_in() ) { + wp_safe_redirect( get_home_url() . wc_get_page_permalink( 'myaccount' ) ); + } else { + wp_safe_redirect( get_home_url() . wc_get_page_permalink( 'shop' ) ); + } + exit; + } + + // Gets the order id from the fulfillment url. + $taler_order_id = sanitize_text_field( wp_unslash( $_GET['order_id'] ) ); + // phpcs:enable + $order_id_array = explode( '-', $taler_order_id ); + $order_id_name = $order_id_array[0]; + $order_id = $order_id_array[1]; + $wc_order = wc_get_order( $order_id ); + $backend_url = $this->gnu_taler_backend_url; + + $payment_confirmation = $this->call_api( + 'GET', + $backend_url . '/private/orders/' . $taler_order_id, + false + ); + $payment_body = $payment_confirmation['message']; + $payment_http_status = $payment_confirmation['http_code']; + + switch ( $payment_http_status ) { + case 200: + // Here we check what kind of http code came back from the backend. + $merchant_order_status_response = json_decode( + $payment_body, + $assoc = true + ); + if ( ! $merchant_order_status_response ) { + wc_add_notice( + __( 'Payment error:', 'woocommerce-gateway-gnutaler' ) . + __( 'backend did not respond', 'woocommerce-gateway-gnutaler' ) + ); + $this->notice( __( 'Payment failed: no reply from Taler backend', 'woocommerce-gateway-gnutaler' ) ); + wp_safe_redirect( $this->get_return_url( $order_id ) ); + exit; + } + if ( 'paid' === $merchant_order_status_response['order_status'] ) { + $this->notice( __( 'Payment succeeded and the user was forwarded to the order confirmed page', 'woocommerce-gateway-gnutaler' ) ); + // Completes the order, storing a transaction ID. + $wc_order->payment_complete( $taler_order_id ); + // Empties the shopping cart. + WC()->cart->empty_cart(); + } else { + wc_add_notice( + __( 'Payment error:', 'woocommerce-gateway-gnutaler' ) . + __( 'backend did not confirm payment', 'woocommerce-gateway-gnutaler' ) + ); + $this->notice( __( 'Backend did not confirm payment', 'woocommerce-gateway-gnutaler' ) ); + } + wp_safe_redirect( $this->get_return_url( $wc_order ) ); + exit; + default: + $this->error( + __( 'An error occurred during the second request to the GNU Taler backend: ', 'woocommerce-gateway-gnutaler' ) + . $payment_http_status . ' - ' . $payment_body + ); + wc_add_notice( __( 'Payment error:', 'woocommerce-gateway-gnutaler' ) . $payment_http_status . ' - ' . $payment_body ); + wp_safe_redirect( $this->get_return_url( $order_id ) ); + break; + } + $cart_url = $woocommerce->cart->wc_get_cart_url(); + if ( is_set( $cart_url ) ) { + wp_safe_redirect( get_home_url() . $cart_url ); + } else { + wp_safe_redirect( wc_get_page_permalink( 'shop' ) ); + } + exit; + } + + /** + * Sends a request to a url via HTTP. + * + * Sends a request to a GNU Taler Backend over HTTP and returns the result. + * The request can be sent as POST or GET. PATCH is not supported. + * + * @param string $method POST or GET supported only. Thanks WordPress. + * @param string $url URL for the request to make to the GNU Taler Backend. + * @param string $body The content of the request (for POST). + * + * @return array The return array will either have the successful return value or a detailed error message. + */ + private function call_api( $method, $url, $body ): array { + $apikey = $this->get_option( 'GNU_Taler_Backend_API_Key' ); + $args = array( + 'timeout' => 30, // In seconds. + 'redirection' => 2, // How often. + 'httpversion' => '1.1', // Taler will support. + 'user-agent' => '', // Minimize information leakage. + 'blocking' => true, // We do nothing without it. + 'headers' => array( + 'Authorization' => 'Bearer ' . $apikey, + ), + 'decompress' => true, + 'limit_response_size' => 1024 * 1024, // More than enough. + ); + if ( $body ) { + $args['body'] = wp_json_encode( $body, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES, 16 ); + $args['headers']['Content-type'] = 'application/json'; + $args['compress'] = true; + } + $this->debug( 'Issuing HTTP ' . $method . ' request to ' . $url . ' with options ' . wp_json_encode( $args ) . ' and body ' . $body ); + + switch ( $method ) { + case 'POST': + $response = wp_remote_post( $url, $args ); + break; + case 'GET': + $response = wp_remote_get( $url, $args ); + break; + default: + $this->debug( 'HTTP method ' . $method . ' not supported' ); + return null; + } + if ( is_wp_error( $response ) ) { + $error_code = $response->get_error_code(); + $error_data = $response->get_error_data( $error_code ); + $this->warning( + sprintf( + /* translators: first placeholder is the error code, second the error data */ + __( 'HTTP failure %1$s with data %2$s', 'woocommerce-gateway-gnutaler' ), + $error_code, + $error_data + ) + ); + + return array( + 'http_code' => 0, + 'message' => $error_code, + ); + } + $http_code = wp_remote_retrieve_response_code( $response ); + $body = wp_remote_retrieve_body( $response ); + $this->debug( + sprintf( + /* translators: first placeholder is the HTTP status code, second the body of the HTTP reply */ + __( 'HTTP status %1$s with response body %2$s', 'woocommerce-gateway-gnutaler' ), + $http_code, + $body + ) + ); + return array( + 'http_code' => $http_code, + 'message' => $body, + ); + } + + /** + * Checks if the configuration is valid. Used by WC to redirect + * the admin to the setup dialog if they enable the backend and + * it is not properly configured. + */ + public function needs_setup() { + $backend_url = $this->gnu_taler_backend_url; + $verify_result = $this->verify_backend_url( + $backend_url, + null + ); + + $this->debug( $verify_result + ? __( 'Backend is ready', 'woocommerce-gateway-gnutaler' ) + : __( 'Backend is not setup correctly', 'woocommerce-gateway-gnutaler' ) ); + + return ! $verify_result; + } + + /** + * Verifying if the url to the backend given in the plugin options is valid or not. + * + * @param string $url URL to the backend. + * @param string $ecurrency expected currency of the order. + * + * @return bool - Returns if valid or not. + */ + private function verify_backend_url( $url, $ecurrency ): bool { + $this->info( 'Verifying backend URL ' . $url ); + + $config = $this->call_api( 'GET', $url . '/config', false ); + $config_http_status = $config['http_code']; + $config_body = $config['message']; + switch ( $config_http_status ) { + case 200: + $info = json_decode( $config_body, $assoc = true ); + if ( ! $info ) { + $this->error( + sprintf( + /* translators: placeholder will be replaced with the URL of the backend */ + __( '/config response of backend at %s did not decode as JSON', 'woocommerce-gateway-gnutaler' ), + $url + ) + ); + return false; + } + $version = $info['version']; + if ( ! $version ) { + $this->error( + sprintf( + /* translators: placeholder will be replaced with the URL of the backend */ + __( "No 'version' field in /config reply from Taler backend at %s", 'woocommerce-gateway-gnutaler' ), + $url + ) + ); + return false; + } + $ver = explode( ':', $version, 3 ); + $current = $ver[0]; + $revision = $ver[1]; + $age = $ver[2]; + if ( ( ! is_numeric( $current ) ) + || ( ! is_numeric( $revision ) ) + || ( ! is_numeric( $age ) + || ( $age > $current ) ) + ) { + $this->error( + sprintf( + /* translators: placeholder will be replaced with the (malformed) version number */ + __( "/config response at backend malformed: '%s' is not a valid version", 'woocommerce-gateway-gnutaler' ), + $version + ) + ); + return false; + } + if ( GNU_TALER_MERCHANT_PROTOCOL_CURRENT < $current - $age ) { + // Our implementation is too old! + $this->error( + sprintf( + /* translators: placeholder will be replaced with the version number */ + __( 'Backend protocol version %s is too new: please update the GNU Taler plugin', 'woocommerce-gateway-gnutaler' ), + $version + ) + ); + return false; + } + if ( GNU_TALER_MERCHANT_PROTOCOL_CURRENT - GNU_TALER_MERCHANT_PROTOCOL_AGE > $current ) { + // Merchant implementation is too old! + $this->error( + sprintf( + /* translators: placeholder will be replaced with the version number */ + __( 'Backend protocol version %s unsupported: please update the backend', 'woocommerce-gateway-gnutaler' ), + $version + ) + ); + return false; + } + $currency = $info['currency']; + if ( ( ! is_null( $ecurrency ) ) && + ( 0 !== strcasecmp( $currency, $ecurrency ) ) ) { + $this->error( + sprintf( + /* translators: first placeholder is the Taler backend currency, second the expected currency from WooCommerce */ + __( 'Backend currency %1$s does not match order currency %2$s', 'woocommerce-gateway-gnutaler' ), + $currency, + $ecurrency + ) + ); + return false; + } + $this->debug( + sprintf( + /* translators: placeholder will be replaced with the URL of the backend */ + __( '/config check for Taler backend at %s succeeded', 'woocommerce-gateway-gnutaler' ), + $url + ) + ); + return true; + default: + $this->error( + sprintf( + /* translators: placeholder will be replaced with the HTTP status code returned by the backend */ + __( 'Backend failed /config request with unexpected HTTP status %s', 'woocommerce-gateway-gnutaler' ), + $config_http_status + ) + ); + return false; + } + } + + /** + * Processes the payment after the checkout + * + * If the payment process finished successfully the user is being redirected to its GNU Taler Wallet. + * If an error occurs it returns void and throws an error. + * + * @param string $order_id ID of the order to get the Order from the WooCommerce Webshop. + * + * @return array|void - Array with result => success and redirection url otherwise it returns void. + */ + public function process_payment( $order_id ) { + // We need the order ID to get any order detailes. + $wc_order = wc_get_order( $order_id ); + + // Gets the url of the backend from the WooCommerce Settings. + $backend_url = $this->gnu_taler_backend_url; + + // Log entry that the customer started the payment process. + $this->info( __( 'User started the payment process with GNU Taler.', 'woocommerce-gateway-gnutaler' ) ); + + if ( ! $this->verify_backend_url( $backend_url, $wc_order->get_currency() ) ) { + 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' ); + $this->error( __( 'Checkout process failed: Invalid backend url.', 'woocommerce-gateway-gnutaler' ) ); + return; + } + $order_json = $this->convert_to_checkout_json( $order_id ); + + $this->info( __( 'Sending POST /private/orders request send to Taler backend', 'woocommerce-gateway-gnutaler' ) ); + $order_confirmation = $this->call_api( + 'POST', + $backend_url . '/private/orders', + $order_json + ); + $order_body = $order_confirmation['message']; + $order_http_status = $order_confirmation['http_code']; + switch ( $order_http_status ) { + case 200: + $post_order_response = json_decode( $order_body, $assoc = true ); + if ( ! $post_order_response ) { + $this->error( + __( 'POST /private/orders request to Taler backend returned 200 OK, but not a JSON body: ', 'woocommerce-gateway-gnutaler' ) + . $order_body + ); + wc_add_notice( __( 'Malformed response from Taler backend. Please contact the system administrator.' ) ); + $wc_order->set_status( 'cancelled' ); + return; + } + $taler_order_id = $post_order_response ['order_id']; + $taler_order_token = $post_order_response ['token']; + if ( ! $taler_order_id ) { + $this->error( + __( 'Response to POST /private/orders request to Taler backend lacks order_id field: ', 'woocommerce-gateway-gnutaler' ) + . $order_body + ); + wc_add_notice( __( 'Malformed response from Taler backend. Please contact the system administrator.', 'woocommerce-gateway-gnutaler' ) ); + $wc_order->set_status( 'cancelled' ); + return; + } + $this->info( __( 'POST /private/orders successful. Redirecting user to Taler Backend.', 'woocommerce-gateway-gnutaler' ) ); + return array( + 'result' => 'success', + 'redirect' => $backend_url . '/orders/' . $taler_order_id . '?token=' . $taler_order_token, + ); + case 404: + $post_order_error = json_decode( $order_body, $assoc = true ); + if ( ! $post_order_error ) { + $this->error( + __( 'POST /private/orders request to Taler backend returned 404, but not a JSON body: ', 'woocommerce-gateway-gnutaler' ) + . $order_body + ); + wc_add_notice( __( 'Malformed response from Taler backend. Please contact the system administrator.', 'woocommerce-gateway-gnutaler' ) ); + $wc_order->set_status( 'cancelled' ); + return; + } + $this->error( + __( 'POST /private/orders request to Taler backend failed: ', 'woocommerce-gateway-gnutaler' ) + . $post_order_error['code'] . '(' + . $order_http_status . '): ' . $order_body + ); + wc_add_notice( __( 'Taler backend not configured correctly. Please contact the system administrator.', 'woocommerce-gateway-gnutaler' ) ); + $wc_order->set_status( 'cancelled' ); + return; + case 410: + // We don't use inventory management via GNU Taler's backend, so this error should never apply. + // Handle with 'default' case below. + default: + $this->error( + __( 'POST /private/orders request to Taler backend failed: ', 'woocommerce-gateway-gnutaler' ) + . $post_order_error['code'] . '(' + . $order_http_status . '): ' + . $order_body + ); + wc_add_notice( __( 'Unexpected problem with the Taler backend. Please contact the system administrator.', 'woocommerce-gateway-gnutaler' ) ); + $wc_order->set_status( 'cancelled' ); + return; + } + } + + /** + * Converts the order into a JSON format that can be send to the GNU Taler Backend. + * + * @param string $order_id ID of the order to get the Order from the WooCommerce Webshop. + * + * @return array - return the JSON Format. + */ + public function convert_to_checkout_json( $order_id ): array { + $wc_order = wc_get_order( $order_id ); + $wc_order_total_amount = $wc_order->get_total(); + $wc_order_currency = $wc_order->get_currency(); + $wc_cart = WC()->cart->get_cart(); + $wc_order_id = $wc_order->get_order_key() . '-' . $wc_order->get_order_number(); + $wc_order_products_array = $this->mutate_products_to_json_format( $wc_cart, $wc_order_currency ); + $refund_delay = $this->get_option( 'GNU_Taler_refund_delay' ); + $order_json = array( + 'order' => array( + 'amount' => $wc_order_currency . ':' . $wc_order_total_amount, + 'summary' => sprintf( + $this->get_option( 'Order_text' ), + $wc_order->get_order_number() + ), + // NOTE: This interacts with the 'add_action' call + // to invoke the 'fulfillment_url_handler' when the + // user goes to this URL! + 'fulfillment_url' => get_home_url() + . '/?wc-api=' + . strtolower( get_class( $this ) ) + . '&order_id=' + . $wc_order_id, + 'order_id' => $wc_order_id, + 'products' => $wc_order_products_array, + 'delivery_location' => $this->mutate_shipping_information_to_json_format( $wc_order ), + ), + ); + if ( isset( $refund_delay ) ) { + $order_json['refund_delay'] = array( + 'd_us' => 1000 * 1000 * 60 * 60 * 24 * intval( $refund_delay ), + ); + } + return $order_json; + } + + /** + * Mutates the products in the cart into a format which can be included in a JSON file. + * + * @param WC_Cart $wc_cart The content of the WooCommerce Cart. + * @param string $wc_order_currency The currency the WooCommerce Webshop uses. + * + * @return array - Returns an array of products. + */ + private function mutate_products_to_json_format( $wc_cart, $wc_order_currency ): array { + $wc_order_products_array = array(); + foreach ( $wc_cart as $product ) { + $wc_order_products_array[] = array( + 'description' => $product['data']->get_title(), + 'quantity' => $product['quantity'], + 'price' => $wc_order_currency . ':' . $product['data']->get_price(), + 'product_id' => strval( $product['data']->get_id() ), + ); + } + return $wc_order_products_array; + } + + /** + * Processes the refund transaction if requested by the system administrator of the webshop + * + * If the refund request is finished successfully it returns an refund url, which can be send to the customer to finish the refund transaction. + * If an error it will throw a WP_Error message and inform the system administrator. + * + * @param WC_Order $wc_order The WooCommerce order object we are processing. + * + * @return array + */ + private function mutate_shipping_information_to_json_format( $wc_order ): array { + $whitechar_encounter = false; + $shipping_address_street = ''; + $shipping_address_street_nr = ''; + + $store_address = $wc_order->get_shipping_address_1( ); + if ( is_null( $store_address ) || empty( $store_address ) ) { + $store_address = $wc_order->get_billing_address_1( ); + } + $store_address_inverted = strrev( $store_address ); + $store_address_array = str_split( $store_address_inverted ); + $country = $wc_order->get_shipping_country (); + if ( is_null( $country ) || empty( $country ) ) { + $country = $wc_order->get_billing_country (); + } + $state = $wc_order->get_shipping_state (); + if ( is_null( $state ) || empty( $state ) ) { + $state = $wc_order->get_billing_state (); + } + $city = $wc_order->get_shipping_city (); + if ( is_null( $city ) || empty( $city ) ) { + $city = $wc_order->get_billing_city (); + } + $postcode = $wc_order->get_shipping_postcode (); + if ( is_null( $postcode ) || empty( $postcode ) ) { + $postcode = $wc_order->get_billing_postcode (); + } + + $this->info ( + sprintf( + 'Shipping address is %s - %s - %s - %s - %s', + $store_address, + $wc_order->get_shipping_country (), + $wc_order->get_shipping_state (), + $wc_order->get_shipping_city (), + $wc_order->get_shipping_postcode ()) ); + + $this->info ( + sprintf( + 'Billing address is %s - %s - %s - %s - %s', + $store_address, + $wc_order->get_billing_country (), + $wc_order->get_billing_state (), + $wc_order->get_billing_city (), + $wc_order->get_billing_postcode ()) ); + $this->info ( + sprintf( + 'Using address is %s - %s - %s - %s - %s', + $store_address, + $country, + $state, + $city, + $postcode)); + + // Split the address into street and street number. + foreach ( $store_address_array as $char ) { + if ( ! $whitechar_encounter ) { + $shipping_address_street .= $char; + } elseif ( ctype_space( $char ) ) { + $whitechar_encounter = true; + } else { + $shipping_address_street .= $char; + } + } + $ret = array( + 'country' => $country, + 'country_subdivision' => $state, + 'town' => $city, + 'post_code' => $postcode, + 'street' => $shipping_address_street, + 'building_number' => $shipping_address_street_nr, + ); + if ( null !== $wc_order->get_shipping_address_2() ) { + $address_lines = array( + $wc_order->get_shipping_address_1(), + $wc_order->get_shipping_address_2(), + ); + $ret['address_lines'] = $address_lines; + } + return $ret; + } + + /** + * Processes the refund transaction if requested by the system administrator of the webshop + * + * If the refund request is finished successfully it returns an refund url, which can be send to the customer to finish the refund transaction. + * If an error it will throw a WP_Error message and inform the system administrator. + * + * @param string $order_id Order id for logging. + * @param string $amount Amount that is requested to be refunded. + * @param string $reason Reason for the refund request. + * + * @return bool|WP_Error - Returns true or throws an WP_Error message in case of error. + */ + public function process_refund( $order_id, $amount = null, $reason = '' ) { + $wc_order = wc_get_order( $order_id ); + + $this->info( + sprintf( + /* translators: first placeholder is the numeric amount, second the currency, and third the reason for the refund */ + __( 'Refund process started with the refunded amount %1$s %2$s and the reason %3$s.' ), + $amount, + $wc_order->get_currency(), + $reason + ) + ); + + // Gets the url of the backend from the WooCommerce Settings. + $backend_url = $this->gnu_taler_backend_url; + + // Get the current status of the order. + $wc_order_status = $wc_order->get_status(); + + // Checks if current status is already set as paid. + if ( ! ( 'processing' === $wc_order_status + || 'on hold' === $wc_order_status + || 'completed' === $wc_order_status ) + ) { + $this->error( __( 'The status of the order does not allow a refund', 'woocommerce-gateway-gnutaler' ) ); + return new WP_Error( 'error', __( 'The status of the order does not allow for a refund.', 'woocommerce-gateway-gnutaler' ) ); + } + + $refund_request = array( + 'refund' => $wc_order->get_currency() . ':' . $amount, + 'reason' => $reason, + ); + $wc_order_id = $wc_order->get_order_key() . '-' . $wc_order->get_order_number(); + $refund_result = $this->call_api( + 'POST', + $backend_url . '/private/orders/' . $wc_order_id . '/refund', + $refund_request + ); + + $refund_http_status = $refund_result['http_code']; + $refund_body = $refund_result['message']; + switch ( $refund_http_status ) { + case 200: + $refund_response = json_decode( $refund_body, $assoc = true ); + if ( ! $refund_response ) { + $this->error( __( 'Malformed 200 response from Taler backend: not even in JSON', 'woocommerce-gateway-gnutaler' ) ); + return new WP_Error( 'error', __( 'Malformed response from Taler backend', 'woocommerce-gateway-gnutaler' ) ); + } + $refund_uri = $refund_response['taler_refund_uri']; + $h_contract = $refund_response['h_contract']; + if ( ( ! $refund_uri ) || ( ! $h_contract ) ) { + $this->error( __( 'Malformed 200 response from Taler backend: lacks taler_refund_uri', 'woocommerce-gateway-gnutaler' ) ); + return new WP_Error( 'error', __( 'Malformed response from Taler backend', 'woocommerce-gateway-gnutaler' ) ); + } + $refund_url = $backend_url + . '/orders/' + . $wc_order_id + . '?h_contract=' + . $h_contract; + $wc_order->add_meta_data( 'GNU_TALER_REFUND_URL', $refund_url ); + $wc_order->update_status( 'refunded' ); + $this->debug( + sprintf( + /* translators: argument is the Taler refund URI */ + + __( 'Received refund URI %s from Taler backend', 'woocommerce-gateway-gnutaler' ), + $refund_uri + ) + ); + $this->notice( + sprintf( + /* translators: argument is the Taler refund URL */ + __( 'The user must visit %s to obtain the refund', 'woocommerce-gateway-gnutaler' ), + $refund_url + ) + ); + return true; + case 403: + return new WP_Error( + 'error', + __( 'Refunds are disabled for this order. Check the refund_delay option for the Taler payment plugin.', 'woocommerce-gateway-gnutaler' ) + ); + case 404: + $refund_error = json_decode( $refund_body, $assoc = true ); + if ( ! $refund_error ) { + return new WP_Error( + 'error', + sprintf( + /* translators: argument is the HTTP status returned by the backend */ + __( 'Unexpected failure %s without Taler error code from Taler backend', 'woocommerce-gateway-gnutaler' ), + $refund_http_status + ) + ); + } + $ec = $refund_error['code']; + switch ( $ec ) { + case 2000: // TALER_EC_INSTANCE_UNKNOWN! + return new WP_Error( + 'error', + __( 'Instance unknown reported by Taler backend', 'woocommerce-gateway-gnutaler' ) + ); + case 2601: // TALER_EC_REFUND_ORDER_ID_UNKNOWN! + return new WP_Error( + 'error', + __( 'Order unknown reported by Taler backend', 'woocommerce-gateway-gnutaler' ) + ); + default: + return new WP_Error( + 'error', + sprintf( + /* translators: placeholder will be replaced with the numeric GNU Taler error code */ + __( 'Unexpected error %s reported by Taler backend', 'woocommerce-gateway-gnutaler' ), + $ec + ) + ); + } + // This line is unreachable. + case 409: + return new WP_Error( + 'error', + __( 'Requested refund amount exceeds original payment. This is not allowed!', 'woocommerce-gateway-gnutaler' ) + ); + case 410: + return new WP_Error( + 'error', + __( 'Wire transfer already happened. It is too late for a refund with Taler!', 'woocommerce-gateway-gnutaler' ) + ); + default: + $refund_error = json_decode( $refund_body, $assoc = true ); + if ( ! $refund_error ) { + $ec = $refund_error['code']; + } else { + $ec = 0; + } + return new WP_Error( + 'error', + sprintf( + /* translators: first placeholder is the HTTP status code, second the numeric GNU Taler error code */ + __( 'Unexpected failure %1$s/%2$s from Taler backend', 'woocommerce-gateway-gnutaler' ), + $refund_http_status, + $ec + ) + ); + } + } + + /** + * Log $msg for debugging + * + * @param string $msg message to log. + */ + private function debug( $msg ): void { + $this->log( 'debug', $msg ); + } + + /** + * Log $msg as a informational + * + * @param string $msg message to log. + */ + private function info( $msg ): void { + $this->log( 'info', $msg ); + } + + /** + * Log $msg as a notice + * + * @param string $msg message to log. + */ + private function notice( $msg ): void { + $this->log( 'notice', $msg ); + } + + /** + * Log $msg as a warning. + * + * @param string $msg message to log. + */ + private function warning( $msg ): void { + $this->log( 'warning', $msg ); + } + + /** + * Log $msg as an error + * + * @param string $msg message to log. + */ + private function error( $msg ): void { + $this->log( 'error', $msg ); + } + + /** + * Log $msg at log $level. + * + * @param string $level log level to use when logging. + * @param string $msg message to log. + */ + private function log( $level, $msg ) { + if ( ! self::$log_enabled ) { + return; + } + if ( function_exists( 'wp_get_current_user()' ) ) { + $user_id = wp_get_current_user(); + if ( ! isset( $user_id ) ) { + $user_id = __( '<user ID not set>', 'woocommerce-gateway-gnutaler' ); + } + } else { + $user_id = 'Guest'; + } + // We intentionally do NOT verify the nonce here, as logging + // should always work. + // phpcs:disable WordPress.Security.NonceVerification + if ( isset ($_GET['order_id'] ) ) { + $order_id = sanitize_text_field( wp_unslash( $_GET['order_id'] ) ); + } else { + $order_id = 'NONE'; + } + // phpcs:enable + if ( empty( self::$log ) ) { + self::$log = wc_get_logger(); + } + self::$log->log( $level, $user_id . '-' . $order_id . ': ' . $msg, array( 'source' => 'gnutaler' ) ); + } + } diff --git a/package.json b/package.json @@ -0,0 +1,27 @@ +{ + "name": "woocommerce-gateway-gnutaler", + "title": "WooCommerce GNU Taler Payments", + "version": "1.1.0", + "author": "Taler Systems SA", + "license": "GPL-3.0+", + "keywords": [], + "engines": { + "node": "^20.12.0", + "npm": "^10.5.0" + }, + "devDependencies": { + "@woocommerce/dependency-extraction-webpack-plugin": "2.2.0", + "@wordpress/scripts": "^27.8.0", + "cross-env": "7.0.3" + }, + "scripts": { + "start": "wp-scripts start", + "build": "wp-scripts build && npm run i18n:build", + "i18n": "npm run i18n:build", + "i18n:build": "npm run i18n:pot && ./bin/build_i18n.sh", + "i18n:pot": "php -d xdebug.max_nesting_level=512 $(which wp) i18n make-pot --exclude=\"node_modules/,languages/,assets/\" --headers='{\"Report-Msgid-Bugs-To\":\"https://bugs.taler.net/\", \"language-team\":\"LANGUAGE <translations@taler.net>\"}' . languages/woocommerce-gateway-gnutaler.pot", + "i18n:json": "$(which wp) i18n make-json languages --no-purge", + "packages-update": "wp-scripts packages-update", + "check-engines": "wp-scripts check-engines" + } +} diff --git a/phpcs.xml b/phpcs.xml @@ -0,0 +1,55 @@ +<?xml version="1.0"?> +<ruleset name="SomewhereWarm-cs"> + <description>SomewhereWarm Coding Standards</description> + + <!-- Exclude paths --> + <exclude-pattern>tests/</exclude-pattern> + <exclude-pattern>*/node_modules/*</exclude-pattern> + <exclude-pattern>*/assets/*</exclude-pattern> + <exclude-pattern>*/vendor/*</exclude-pattern> + + <!-- Configs --> + <config name="minimum_supported_wp_version" value="6.0" /> + <config name="testVersion" value="7.1-" /> + + <!-- Rules --> + <rule ref="WooCommerce-Core"> + <exclude name="Core.Commenting.CommentTags.AuthorTag" /> + <exclude name="WordPress.PHP.DontExtract" /> + </rule> + + <rule ref="WordPress-Extra"> + <exclude name="Generic.Commenting.DocComment.SpacingAfter" /> + <exclude name="Generic.Files.LineEndings.InvalidEOLChar" /> + <exclude name="Generic.Functions.FunctionCallArgumentSpacing.SpaceBeforeComma" /> + <exclude name="Generic.WhiteSpace" /> + <exclude name="PEAR.Functions.FunctionCallSignature" /> + <exclude name="Squiz.Commenting" /> + <exclude name="Squiz.PHP.DisallowSizeFunctionsInLoops.Found" /> + <exclude name="Squiz.WhiteSpace" /> + <exclude name="WordPress.Arrays" /> + <exclude name="WordPress.Files.FileName" /> + <exclude name="WordPress.NamingConventions" /> + <exclude name="WordPress.Security.ValidatedSanitizedInput.MissingUnslash" /> + <exclude name="WordPress.WP.I18n.NonSingularStringLiteralText" /> + <exclude name="WordPress.WhiteSpace" /> + <exclude name="WordPress.Security.EscapeOutput" /> + <exclude name="Squiz.PHP.EmbeddedPhp" /> + </rule> + + <rule ref="PHPCompatibility"> + <exclude-pattern>tests/</exclude-pattern> + </rule> + + <rule ref="WordPress.Security.EscapeOutput"> + <properties> + <!-- e.g. body_class, the_content, the_excerpt --> + <property name="customAutoEscapedFunctions" type="array" value="0=>woocommerce_wp_select,1=>wcs_help_tip,2=>admin_url,3=>wc_price"/> + <!-- e.g. esc_attr, esc_html, esc_url--> + <property name="customEscapingFunctions" type="array" value="0=>wcs_json_encode,1=>htmlspecialchars,2=>wp_kses_allow_underscores"/> + <!-- e.g. _deprecated_argument, printf, _e--> + <property name="customPrintingFunctions" type="array" value=""/> + </properties> + </rule> + +</ruleset> diff --git a/readme.txt b/readme.txt @@ -26,7 +26,7 @@ For refunds, the plugin sends a refund request to the GNU Taler back-end and rec == Installation == -1. Ensure you have latest version of WooCommerce plugin installed +1. Ensure you have latest version of WooCommerce plugin installed and are running PHP 7.1 or later 2. Upload (or copy) the plugin directory (`GNU-Taler-Payment-Gateway`) to the `/wp-content/plugins/` directory of your WordPress/WooCommerce site, or install the plugin through the WordPress plugins interface at `<your-site>/wp-admin/plugins.php`. 3. Activate the GNU Taler Payment Gateway for WooCommerce plugin through the 'Plugins' screen in WordPress. 4. In the WordPress interface, navigate to WooCommerce -> Settings-> Payments tab. Locate the `GNU Taler Gateway` entry and click the `Set up` button to configure the plugin. @@ -43,11 +43,17 @@ A: Yes, the customer needs the GNU Taler Wallet. The customer can get a wallet h *Q: Can the plugin work without WooCommerce* -A: For the plugin to work correctly you need to have the WooCommerce plugin installed on a WordPress site +A: For the plugin to work correctly you need to have the WooCommerce plugin installed on a WordPress site. == Changelog == += 1.1.0 = + +* Adapt plugin to new "Blocks" API of WooCommerce taking inspiration + from the woocommerce-gateway-dummy (GPLv3+). +* Added bin/install_phpcs.sh to easily install phpcs and dependencies + = 0.9.4 = * Use billing address in contract if shipping address is not given diff --git a/resources/js/frontend/index.js b/resources/js/frontend/index.js @@ -0,0 +1,61 @@ +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { sprintf, __ } from '@wordpress/i18n'; +import { registerPaymentMethod } from '@woocommerce/blocks-registry'; +import { decodeEntities } from '@wordpress/html-entities'; +import { getSetting } from '@woocommerce/settings'; + +const settings = getSetting('gnutaler_data', {}); + +const defaultLabel = __( + 'GNU Taler', + 'woocommerce-gateway-gnutaler' +); +const label = decodeEntities(settings.title) || defaultLabel; + +/** + * Content component + */ +const Content = () => { + return decodeEntities(settings.description || ''); +}; + +/** + * Label component + * + * @param {*} props Props from payment API. + */ +const Label = (props) => { + const { PaymentMethodLabel } = props.components; + return <PaymentMethodLabel text ={ label } />; +}; + +/** + * GNU Taler payment method config object. + */ +const GnutalerPaymentMethod = { + name: 'gnutaler', + label: <Label />, + content: <Content />, + edit: <Content />, + canMakePayment: () => true, + ariaLabel: label, + supports: { + features: settings.supports, + }, +}; + +registerPaymentMethod( GnutalerPaymentMethod ); diff --git a/snippets/kudos-currency.php b/snippets/kudos-currency.php @@ -1,3 +1,5 @@ +<?php + /** * License: Public domain. * diff --git a/webpack.config.js b/webpack.config.js @@ -0,0 +1,47 @@ +const defaultConfig = require('@wordpress/scripts/config/webpack.config'); +const WooCommerceDependencyExtractionWebpackPlugin = require('@woocommerce/dependency-extraction-webpack-plugin'); +const path = require('path'); + +const wcDepMap = { + '@woocommerce/blocks-registry': ['wc', 'wcBlocksRegistry'], + '@woocommerce/settings' : ['wc', 'wcSettings'] +}; + +const wcHandleMap = { + '@woocommerce/blocks-registry': 'wc-blocks-registry', + '@woocommerce/settings' : 'wc-settings' +}; + +const requestToExternal = (request) => { + if (wcDepMap[request]) { + return wcDepMap[request]; + } +}; + +const requestToHandle = (request) => { + if (wcHandleMap[request]) { + return wcHandleMap[request]; + } +}; + +// Export configuration. +module.exports = { + ...defaultConfig, + entry: { + 'frontend/blocks': '/resources/js/frontend/index.js', + }, + output: { + path: path.resolve( __dirname, 'assets/js' ), + filename: '[name].js', + }, + plugins: [ + ...defaultConfig.plugins.filter( + (plugin) => + plugin.constructor.name !== 'DependencyExtractionWebpackPlugin' + ), + new WooCommerceDependencyExtractionWebpackPlugin({ + requestToExternal, + requestToHandle + }) + ] +}; diff --git a/woocommerce-gateway-gnutaler.php b/woocommerce-gateway-gnutaler.php @@ -0,0 +1,116 @@ +<?php +/** + * Plugin Name: WooCommerce GNU Taler Payments Gateway + * Plugin URI: https://git.taler.net/gnu-taler-payment-for-woocommerce.git + * Description: Adds the GNU Taler Payments gateway to your WooCommerce website. + * Version: 1.1.0 + * + * Author: Taler Systems SA + * Author URI: https://taler-systems.com/ + * + * Text Domain: woocommerce-gateway-gnutaler + * Domain Path: /i18n/languages/ + * + * Requires at least: 4.2 + * Tested up to: 6.6 + * + * Copyright: © 2009-2025 Taler Systems SA (using template from Automattic) + * License: GNU General Public License v3.0 + * License URI: http://www.gnu.org/licenses/gpl-3.0.html + */ + +// Exit if accessed directly. +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * WC Gnutaler Payment gateway plugin class. + * + * @class WC_Gnutaler_Payments + */ +class WC_Gnutaler_Payments { + + /** + * Plugin bootstrapping. + */ + public static function init() { + + // Gnutaler Payments gateway class. + add_action( 'plugins_loaded', array( __CLASS__, 'includes' ), 0 ); + + // Make the Gnutaler Payments gateway available to WC. + add_filter( 'woocommerce_payment_gateways', array( __CLASS__, 'add_gateway' ) ); + + // Registers WooCommerce Blocks integration. + add_action( 'woocommerce_blocks_loaded', array( __CLASS__, 'woocommerce_gateway_gnutaler_woocommerce_block_support' ) ); + } + + /** + * Add the GNU Taler Payment gateway to the list of available gateways. + * + * @param array + */ + public static function add_gateway( $gateways ) { + + $options = get_option( 'woocommerce_gnutaler_settings', array() ); + + if ( isset( $options['hide_for_non_admin_users'] ) ) { + $hide_for_non_admin_users = $options['hide_for_non_admin_users']; + } else { + $hide_for_non_admin_users = 'no'; + } + + if ( ( 'yes' === $hide_for_non_admin_users && current_user_can( 'manage_options' ) ) || 'no' === $hide_for_non_admin_users ) { + $gateways[] = 'WC_Gateway_Gnutaler'; + } + return $gateways; + } + + /** + * Plugin includes. + */ + public static function includes() { + + // Make the WC_Gateway_Gnutaler class available. + if ( class_exists( 'WC_Payment_Gateway' ) ) { + require_once 'includes/class-wc-gateway-gnutaler.php'; + } + } + + /** + * Plugin url. + * + * @return string + */ + public static function plugin_url() { + return untrailingslashit( plugins_url( '/', __FILE__ ) ); + } + + /** + * Plugin url. + * + * @return string + */ + public static function plugin_abspath() { + return trailingslashit( plugin_dir_path( __FILE__ ) ); + } + + /** + * Registers WooCommerce Blocks integration. + * + */ + public static function woocommerce_gateway_gnutaler_woocommerce_block_support() { + if ( class_exists( 'Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType' ) ) { + require_once 'includes/blocks/class-wc-gnutaler-payments-blocks.php'; + add_action( + 'woocommerce_blocks_payment_method_type_registration', + function ( Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry $payment_method_registry ) { + $payment_method_registry->register( new WC_Gateway_Gnutaler_Blocks_Support() ); + } + ); + } + } +} + +WC_Gnutaler_Payments::init();