Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.59% covered (warning)
63.59%
138 / 217
16.67% covered (danger)
16.67%
2 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bitcoin_Gateway
63.59% covered (warning)
63.59%
138 / 217
16.67% covered (danger)
16.67%
2 / 12
107.67
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 get_method_description
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
3.14
 get_view_scheduled_actions_link
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 get_formatted_exchange_rate_string
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 is_site_using_full_site_editing
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 get_formatted_link_to_order_confirmation_edit
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 process_admin_options
29.63% covered (danger)
29.63%
8 / 27
0.00% covered (danger)
0.00%
0 / 1
13.71
 init_form_fields
87.34% covered (warning)
87.34%
69 / 79
0.00% covered (danger)
0.00%
0 / 1
4.03
 is_available
66.67% covered (warning)
66.67%
14 / 21
0.00% covered (danger)
0.00%
0 / 1
10.37
 process_payment
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
6.33
 get_xpub
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
20
 get_price_margin_percent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * The main payment gateway class for the plugin.
4 *
5 * @package    brianhenryie/bh-wp-bitcoin-gateway
6 */
7
8namespace BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce;
9
10use BrianHenryIE\WP_Bitcoin_Gateway\API\API;
11use BrianHenryIE\WP_Bitcoin_Gateway\API\Helpers\JsonMapper\JsonMapper_Helper;
12use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception;
13use BrianHenryIE\WP_Bitcoin_Gateway\API_Interface;
14use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Currency;
15use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\UnknownCurrencyException;
16use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money;
17use BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce\Helpers\WC_Order_Meta_Helper;
18use BrianHenryIE\WP_Bitcoin_Gateway\Settings_Interface;
19use Exception;
20use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address;
21use Psr\Log\LoggerAwareTrait;
22use Psr\Log\LoggerInterface;
23use Psr\Log\LogLevel;
24use WC_Order;
25use WC_Payment_Gateway;
26use WC_Product;
27
28/**
29 * Simple instance of WC_Payment Gateway. Defines the admin settings and processes the payment.
30 *
31 * @see WC_Settings_API
32 */
33class Bitcoin_Gateway extends WC_Payment_Gateway {
34    use LoggerAwareTrait;
35
36    /**
37     * The default id for an instance of this gateway (typically there will only be one).
38     *
39     * @override WC_Settings_API::$id
40     *
41     * @var string
42     */
43    public $id = 'bh_bitcoin';
44
45    /**
46     * A cache so {@see Bitcoin_Gateway::is_available()} only runs once.
47     */
48    protected ?bool $is_available_cache = null;
49
50    /**
51     * Constructor for the gateway.
52     *
53     * Used to generate new wallets when the xpub is entered, and to fetch addresses when orders are placed.
54     *
55     * @param API_Interface             $api The main plugin functions.
56     * @param API_WooCommerce_Interface $api_woocommerce The WooCommerce specific functions.
57     * @param Settings_Interface        $plugin_settings Used to read the gateway settings from wp_options before they are initialized in this class.
58     * @param LoggerInterface           $logger PSR logger.
59     */
60    public function __construct(
61        protected API_Interface $api,
62        protected API_WooCommerce_Interface $api_woocommerce,
63        protected Settings_Interface $plugin_settings,
64        LoggerInterface $logger,
65    ) {
66        /**
67         * Set a null logger to prevent null pointer exceptions. Later this will be correctly set
68         * with the plugin's real logger.
69         *
70         * @see Payment_Gateways::add_logger_to_gateways()
71         */
72        $this->setLogger( $logger );
73
74        $this->icon               = plugins_url( 'assets/bitcoin.png', 'bh-wp-bitcoin-gateway/bh-wp-bitcoin-gateway.php' );
75        $this->has_fields         = false;
76        $this->method_title       = __( 'Bitcoin', 'bh-wp-bitcoin-gateway' );
77        $this->method_description = __( 'Accept Bitcoin payments. Customers are shown payment instructions and a QR code. Orders are marked paid once payment is confirmed on the blockchain.', 'bh-wp-bitcoin-gateway' );
78
79        // Load the settings.
80        $this->init_form_fields();
81        $this->init_settings();
82
83        // Define user set variables.
84        $this->title       = $this->get_option( 'title' );
85        $this->description = $this->get_option( 'description' );
86
87        /**
88         * This class extends WC_Payment_Gateway which extends WC_Settings_API. Each instance needs
89         *
90         * @see WC_Settings_API::process_admin_options()
91         */
92        add_action(
93            'woocommerce_update_options_payment_gateways_' . $this->id,
94            $this->process_admin_options( ... )
95        );
96        add_action( 'admin_notices', array( $this, 'display_errors' ), 9999 );
97    }
98
99    /**
100     * Return the description for admin screens.
101     *
102     * @see parent::get_method_description()
103     *
104     * @return string
105     */
106    public function get_method_description() {
107        $method_description = $this->method_description . PHP_EOL;
108
109        $method_description .= PHP_EOL;
110        $method_description .= PHP_EOL;
111        $method_description .= $this->get_formatted_exchange_rate_string();
112        $method_description .= ' • ';
113        $method_description .= $this->get_view_scheduled_actions_link();
114
115        if ( $this->is_site_using_full_site_editing() ) {
116            $method_description .= PHP_EOL;
117            $method_description .= PHP_EOL;
118            $method_description .= $this->get_formatted_link_to_order_confirmation_edit();
119        }
120
121        $filtered = apply_filters( 'woocommerce_gateway_method_description', $method_description, $this );
122
123        return is_string( $filtered ) ? $filtered : $method_description;
124    }
125
126    /**
127     * Build a link to Action Scheduler's view, filtered to this plugin's jobs.
128     */
129    protected function get_view_scheduled_actions_link(): string {
130        return sprintf(
131            '<a href="%s">View Scheduled Actions</a>',
132            add_query_arg(
133                array(
134                    'page'    => 'action-scheduler',
135                    'status'  => 'pending',
136                    'orderby' => 'schedule',
137                    'order'   => 'desc',
138                    's'       => 'bh_wp_bitcoin_gateway',
139                ),
140                admin_url(
141                    'tools.php'
142                )
143            )
144        );
145    }
146
147    /**
148     * Returns the exchange rate in a string, e.g. 'Current exchange rate: 1 BTC = $100,000'.
149     *
150     * @throws UnknownCurrencyException If the store currency is not an ISO 4217 brick/money supported currency code.
151     */
152    protected function get_formatted_exchange_rate_string(): string {
153        try {
154            $currency = Currency::of( get_woocommerce_currency() );
155        } catch ( UnknownCurrencyException ) {
156            $currency = Currency::of( 'USD' );
157        }
158        $exchange_rate = $this->api->get_exchange_rate( $currency );
159        if ( is_null( $exchange_rate ) ) {
160            // TODO: Also display an admin notice with instruction to configure / retry.
161            return 'Error fetching exchange rate. Gateway will be unavailable to customers until an exchange rate is available.';
162        }
163        return sprintf(
164            'Current exchange rate: 1 BTC = %s',
165            wc_price(
166                $exchange_rate->getAmount()->toFloat(),
167                array(
168                    'currency' => $exchange_rate->getCurrency()->getCurrencyCode(),
169                )
170            ),
171        );
172    }
173
174    /**
175     * Determine if the site is using a full site editing theme.
176     */
177    protected function is_site_using_full_site_editing(): bool {
178        return function_exists( 'wp_is_block_theme' ) && wp_is_block_theme();
179    }
180
181    /**
182     * Get an anchor link for the full site editing page for the order confirmation template.
183     */
184    protected function get_formatted_link_to_order_confirmation_edit(): string {
185        return sprintf(
186            '<a href="%s" target="_blank">Edit the order confirmation page</a>.',
187            add_query_arg(
188                array(
189                    'postType' => 'wp_template',
190                    'postId'   => 'woocommerce/woocommerce//order-confirmation',
191                    'canvas'   => 'edit',
192                ),
193                admin_url( 'site-editor.php' )
194            )
195        );
196    }
197
198    /**
199     * When saving the options, if the xpub is changed, initiate a background job to generate addresses.
200     *
201     * @see \WC_Settings_API::process_admin_options()
202     *
203     * @return bool
204     */
205    public function process_admin_options() {
206
207        $xpub_before = $this->get_xpub();
208
209        // This gets the `$_POST` data and saves it.
210        $options_updated = parent::process_admin_options();
211
212        // Regardless whether the wallet address has changed, ensure it exists.
213
214        $xpub_after = $this->get_xpub();
215
216        if ( ! is_null( $xpub_after ) ) {
217            $this->api->get_or_save_wallet_for_master_public_key(
218                $xpub_after,
219                array(
220                    'integration' => WooCommerce_Integration::class,
221                    'gateway_id'  => $this->id,
222                )
223            );
224        }
225
226        // If nothing changed, we can return early.
227        if ( ! $options_updated ) {
228            return false;
229        }
230
231        // Other settings may have changed.
232        if ( $xpub_after === $xpub_before ) {
233            // Definitely no change!
234            return $options_updated;
235        }
236
237        if ( is_null( $xpub_after ) ) {
238            // The setting value was deleted.
239            // TODO: maybe mark the wallet inactive.
240            return $options_updated;
241        }
242
243        $this->logger->info(
244            'New xpub key set for gateway {gateway_name}: {xpub_after}',
245            array(
246                'gateway_id'   => $this->id,
247                'gateway_name' => $this->get_method_title(),
248                'xpub_before'  => $xpub_before,
249                'xpub_after'   => $xpub_after,
250            )
251        );
252
253        // TODO: maybe mark the previous xpub's wallet as "inactive". (although it could be in use in another instance of the gateway).
254
255        return $options_updated;
256    }
257
258    /**
259     * Initialize Gateway Settings Form Fields
260     *
261     * @see WC_Settings_API::init_form_fields()
262     *
263     * @return void
264     */
265    public function init_form_fields() {
266
267        $settings_fields = array(
268
269            'enabled'      => array(
270                'title'   => __( 'Enable/Disable', 'bh-wp-bitcoin-gateway' ),
271                'type'    => 'checkbox',
272                'label'   => __( 'Enable Bitcoin Payment', 'bh-wp-bitcoin-gateway' ),
273                'default' => 'yes',
274            ),
275
276            'title'        => array(
277                'title'       => __( 'Title', 'bh-wp-bitcoin-gateway' ),
278                'type'        => 'text',
279                'description' => __( 'The payment method title the customer sees during checkout.', 'bh-wp-bitcoin-gateway' ),
280                'default'     => __( 'Bitcoin', 'bh-wp-bitcoin-gateway' ),
281                'desc_tip'    => false,
282            ),
283
284            'description'  => array(
285                'title'       => __( 'Description', 'bh-wp-bitcoin-gateway' ),
286                'type'        => 'text',
287                'description' => __( 'Text the customer will see when the gateway is chosen at checkout.', 'bh-wp-bitcoin-gateway' ),
288                'default'     => __( 'Pay quickly and easily with Bitcoin', 'bh-wp-bitcoin-gateway' ),
289                'desc_tip'    => false,
290            ),
291
292            'xpub'         => array(
293                'title'       => __( 'Master Public Key', 'bh-wp-bitcoin-gateway' ),
294                'type'        => 'text',
295                'description' => __( 'The xpub/ypub/zpub for your Bitcoin wallet, which we use to locally generate the addresses to pay to (no API calls). Find it in Electrum under menu:wallet/information. It looks like <code>xpub1a2bc3d4longalphanumericstring</code>', 'bh-wp-bitcoin-gateway' ),
296                'default'     => '',
297                'desc_tip'    => false,
298            ),
299
300            'price_margin' => array(
301                'title'             => __( 'price-margin', 'bh-wp-bitcoin-gateway' ),
302                'type'              => 'number',
303                'description'       => __( 'A percentage shortfall from the shown price which will be accepted, to allow for volatility.', 'bh-wp-bitcoin-gateway' ),
304                'default'           => '2',
305                'custom_attributes' => array(
306                    'min'  => 0,
307                    'max'  => 100,
308                    'step' => 1,
309                ),
310                'desc_tip'          => false,
311            ),
312        );
313
314        /**
315         * Let's get some products, filter to one that can be purchased, then use it to link to the checkout so
316         * the admin can see what it will all look like.
317         *
318         * @var WC_Product[] $products
319         */
320        $products = wc_get_products(
321            array(
322                'status' => 'publish',
323                'limit'  => 10,
324            )
325        );
326        $products = array_filter(
327            $products,
328            fn( WC_Product $product ): bool => $product->is_purchasable()
329        );
330        if ( ! empty( $products ) ) {
331            $a_product = array_pop( $products );
332
333            $checkout_url                                   = add_query_arg(
334                array(
335                    'add-to-cart'     => $a_product->get_id(),
336                    'payment_gateway' => $this->id,
337                ),
338                wc_get_checkout_url()
339            );
340            $settings_fields['description']['description'] .= ' <a href="' . esc_url( $checkout_url ) . '" title="Adds an item to your cart and opens the checkout in a new tab.">Visit checkout</a>.';
341        }
342
343        $saved_xpub = $this->get_option( 'xpub' );
344        if ( ! empty( $saved_xpub ) ) {
345            $settings_fields['xpub']['description'] = '<a href="' . esc_url( admin_url( 'edit.php?post_type=bh-bitcoin-address' ) ) . '">View addresses</a>';
346        }
347
348        $settings_fields['price_margin']['description'] .= __( 'See: ', 'bh-wp-bitcoin-gateway' ) . '<a href="https://buybitcoinworldwide.com/volatility-index/" target="_blank">Bitcoin Volatility</a>.';
349
350        $log_levels        = array( 'none', LogLevel::ERROR, LogLevel::WARNING, LogLevel::NOTICE, LogLevel::INFO, LogLevel::DEBUG );
351        $log_levels_option = array();
352        foreach ( $log_levels as $log_level ) {
353            $log_levels_option[ $log_level ] = ucfirst( $log_level );
354        }
355
356        $settings_fields['log_level'] = array(
357            'title'       => __( 'Log Level', 'text-domain' ),
358            'label'       => __( 'Enable Logging', 'text-domain' ),
359            'type'        => 'select',
360            'options'     => $log_levels_option,
361            'description' => __( 'Increasingly detailed levels of logs. ', 'bh-wp-bitcoin-gateway' ) . '<a href="' . admin_url( 'admin.php?page=bh-wp-bitcoin-gateway-logs' ) . '">View Logs</a>',
362            'desc_tip'    => false,
363            'default'     => 'info',
364        );
365
366        $this->form_fields = (array) apply_filters( 'wc_gateway_bitcoin_form_fields', $settings_fields, $this->id );
367    }
368
369
370    /**
371     * Returns false when the gateway is not configured / has no addresses to use / has no exchange rate available.
372     *
373     * @see WC_Payment_Gateways::get_available_payment_gateways()
374     * @overrides {@see WC_Payment_Gateway::is_available()}
375     *
376     * @return bool
377     */
378    public function is_available() {
379
380        // Without the cache, when only one address was available, and an order was placed, we reached a point
381        // where no addresses were available, so the placing the order would fail in the UI. In the backend the
382        // order exists and the payment address is assigned.
383        // By caching it for 15 seconds, we should be ok.
384
385        // TODO: always keep more than two addresses available.
386
387        $is_available_cache_key = 'bh-wp-bitcoin-gateway-available:' . self::class . $this->id;
388
389        $is_available_cache_string = get_transient( $is_available_cache_key );
390        if ( is_string( $is_available_cache_string ) ) {
391            /** @var mixed|array{is_available:bool} $is_available_cache */
392            $is_available_cache = json_decode( $is_available_cache_string, true );
393            if ( is_array( $is_available_cache )
394                && isset( $is_available_cache['is_available'] )
395                && is_bool( $is_available_cache['is_available'] )
396            ) {
397                return $is_available_cache['is_available'];
398            }
399        }
400
401        if ( is_bool( $this->is_available_cache ) ) {
402            return $this->is_available_cache;
403        }
404
405        if ( ! $this->api_woocommerce->is_unused_address_available_for_gateway( $this ) ) {
406            $this->is_available_cache = false;
407        } elseif ( is_null( $this->api->get_exchange_rate( Currency::of( get_woocommerce_currency() ) ) ) ) {
408            $this->is_available_cache = false;
409        } else {
410            $this->is_available_cache = parent::is_available();
411        }
412
413        set_transient(
414            $is_available_cache_key,
415            wp_json_encode( array( 'is_available' => $this->is_available_cache ) ),
416            15
417        );
418
419        return $this->is_available_cache;
420    }
421
422    /**
423     * Process the payment and return the result.
424     *
425     * @param int $order_id The id of the order being paid.
426     *
427     * @see WC_Payment_Gateway::process_payment()
428     *
429     * @return array{result:string, redirect:string}
430     * @throws BH_WP_Bitcoin_Gateway_Exception Throws an exception when no address is available (which is caught by WooCommerce and displayed at checkout).
431     */
432    public function process_payment( $order_id ) {
433
434        $order = wc_get_order( $order_id );
435
436        if ( ! ( $order instanceof WC_Order ) ) {
437            // This should never happen.
438            throw new BH_WP_Bitcoin_Gateway_Exception( __( 'Error creating order.', 'bh-wp-bitcoin-gateway' ) );
439        }
440
441        $fiat_total = Money::of( $order->get_total(), $order->get_currency() );
442
443        $btc_total = $this->api->convert_fiat_to_btc( $fiat_total );
444
445        /**
446         * There should never really be an exception here, since the availability of a fresh address was checked before
447         * offering the option to pay by Bitcoin.
448         *
449         * @see Bitcoin_Gateway::is_available()
450         */
451        try {
452            /**
453             *
454             * @see WC_Order_Meta_Helper::BITCOIN_ADDRESS_META_KEY
455             * @see Bitcoin_Address::get_raw_address()
456             */
457            $btc_address = $this->api_woocommerce->assign_unused_address_to_order( $order, $btc_total );
458        } catch ( Exception $e ) {
459            $this->logger->error( $e->getMessage(), array( 'exception' => $e ) );
460            throw new BH_WP_Bitcoin_Gateway_Exception( 'Unable to find Bitcoin address to send to. Please choose another payment method.' );
461        }
462
463        $order_meta_helper = new WC_Order_Meta_Helper( new JsonMapper_Helper()->build() );
464
465        /**
466         * Record the exchange rate at the time the order was placed.
467         *
468         * Although we're allowing for `::get_exchange_rate()` = `null` here, that should never happen since it was
469         * checked before the gateway was offered as a payment option.
470         */
471        $exchange_rate = $this->api->get_exchange_rate( Currency::of( $order->get_currency() ) );
472        if ( $exchange_rate ) {
473            $order_meta_helper->set_exchange_rate( wc_order: $order, exchange_rate:$exchange_rate, save_now: false );
474        }
475
476        // TODO: the `save_now` here might better be `false` depending on how `update_status` works.
477        $order_meta_helper->set_btc_total_price( wc_order: $order, btc_total:$btc_total, save_now: true );
478
479        $btc_total_display = $btc_total->getAmount()->toFloat();
480
481        // Mark as on-hold (we're awaiting the payment).
482        /* translators: %F: The order total in BTC */
483        $order->update_status( 'on-hold', sprintf( __( 'Awaiting Bitcoin payment of %F to address: ', 'bh-wp-bitcoin-gateway' ), $btc_total_display ) . '<a target="_blank" href="https://www.blockchain.com/btc/address/' . $btc_address->get_raw_address() . "\">{$btc_address->get_raw_address()}</a>.\n\n" );
484
485        $order->save();
486
487        // Reduce stock levels.
488        wc_reduce_stock_levels( $order_id );
489
490        // Remove cart.
491        WC()->cart->empty_cart();
492
493        // Return thankyou redirect.
494        return array(
495            'result'   => 'success',
496            'redirect' => $this->get_return_url( $order ),
497        );
498    }
499
500    /**
501     * Returns the configured xpub for the gateway, so new addresses can be generated.
502     *
503     * TODO: This should be ~`{master_public_key:string, wp_post_id?:int}`.
504     * TODO: rename to get_master_public_key() ?
505     *
506     * @used-by API::generate_new_addresses_for_wallet()
507     */
508    public function get_xpub(): ?string {
509        // TODO: validate xpub format when setting (in JS).
510        return isset( $this->settings['xpub'] ) && is_string( $this->settings['xpub'] ) && ! empty( $this->settings['xpub'] )
511            ? $this->settings['xpub']
512            : null;
513    }
514
515    /**
516     * Price margin is the allowable difference between the amount received and the amount expected.
517     *
518     * @used-by API::get_order_details()
519     *
520     * @return float
521     */
522    public function get_price_margin_percent(): float {
523        $price_margin = $this->settings['price_margin'];
524        return is_numeric( $price_margin ) ? floatval( $price_margin ) : 2.0;
525    }
526}