Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
25.19% covered (danger)
25.19%
34 / 135
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bitcoin_Gateway
25.19% covered (danger)
25.19%
34 / 135
40.00% covered (danger)
40.00%
2 / 5
187.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 process_admin_options
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 init_form_fields
0.00% covered (danger)
0.00%
0 / 81
0.00% covered (danger)
0.00%
0 / 1
20
 is_available
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 process_payment
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
5.12
 get_xpub
n/a
0 / 0
n/a
0 / 0
1
 get_price_margin_percent
n/a
0 / 0
n/a
0 / 0
1
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\WooCommerce;
9
10use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\Bitcoin_Wallet_Factory;
11use BrianHenryIE\WP_Bitcoin_Gateway\Settings_Interface;
12use Exception;
13use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs;
14use BrianHenryIE\WP_Bitcoin_Gateway\API\Addresses\Bitcoin_Address;
15use BrianHenryIE\WP_Bitcoin_Gateway\API_Interface;
16use Psr\Log\LoggerAwareTrait;
17use Psr\Log\LogLevel;
18use Psr\Log\NullLogger;
19use WC_Order;
20use WC_Payment_Gateway;
21
22/**
23 * Simple instance of WC_Payment Gateway. Defines the admin settings and processes the payment.
24 *
25 * @see WC_Settings_API
26 */
27class Bitcoin_Gateway extends WC_Payment_Gateway {
28    use LoggerAwareTrait;
29
30    /**
31     * The default id for an instance of this gateway (typically there will only be one).
32     *
33     * @override WC_Settings_API::$id
34     *
35     * @var string
36     */
37    public $id = 'bitcoin_gateway';
38
39    /**
40     * Used to generate new wallets when the xpub is entered, and to fetch addresses when orders are placed.
41     *
42     * @var ?API_Interface
43     */
44    protected ?API_Interface $api = null;
45
46    /**
47     * The plugin settings.
48     *
49     * Used to read the gateway settings from wp_options before they are initialized in this class.
50     *
51     * @var Settings_Interface
52     */
53    protected Settings_Interface $plugin_settings;
54
55    /**
56     * Is this gateway enabled and has a payment address available.
57     *
58     * Previously we were using a static value in a method to store this, but that caused problems with tests, and
59     * would be an issue with Duplicate Payment Gateways.
60     *
61     * @used-by Bitcoin_Gateway::is_available()
62     *
63     * @var ?bool
64     */
65    protected ?bool $is_available = null;
66
67    /**
68     * Constructor for the gateway.
69     *
70     * @param ?API_Interface $api The main plugin functions.
71     */
72    public function __construct( ?API_Interface $api = null ) {
73        // TODO: Set the logger externally.
74        $this->setLogger( new NullLogger() );
75
76        $this->api = $api ?? $GLOBALS['bh_wp_bitcoin_gateway'];
77
78        $this->plugin_settings = new \BrianHenryIE\WP_Bitcoin_Gateway\API\Settings();
79
80        $this->icon               = plugins_url( 'assets/bitcoin.png', 'bh-wp-bitcoin-gateway/bh-wp-bitcoin-gateway.php' );
81        $this->has_fields         = false;
82        $this->method_title       = __( 'Bitcoin', 'bh-wp-bitcoin-gateway' );
83        $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' );
84
85        // Load the settings.
86        $this->init_form_fields();
87        $this->init_settings();
88
89        // Define user set variables.
90        $this->title       = $this->get_option( 'title' );
91        $this->description = $this->get_option( 'description' );
92
93        // Actions.
94        add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, array( $this, 'process_admin_options' ) );
95        add_action( 'admin_notices', array( $this, 'display_errors' ), 9999 );
96    }
97
98    /**
99     * When saving the options, if the xpub is changed, initiate a background job to generate addresses.
100     *
101     * @see \WC_Settings_API::process_admin_options()
102     *
103     * @return bool
104     */
105    public function process_admin_options() {
106
107        $xpub_before = $this->get_xpub();
108
109        $is_processed = parent::process_admin_options();
110        $xpub_after   = $this->get_xpub();
111
112        if ( $xpub_before !== $xpub_after && ! empty( $xpub_after ) ) {
113            $gateway_name = $this->get_method_title() === $this->get_method_description() ? $this->get_method_title() : $this->get_method_title() . ' (' . $this->get_method_description() . ')';
114            $this->logger->info(
115                "New xpub key set for gateway $gateway_name$xpub_after",
116                array(
117                    'gateway_id'  => $this->id,
118                    'xpub_before' => $xpub_before,
119                    'xpub_after'  => $xpub_after,
120                )
121            );
122
123            if ( ! is_null( $this->api ) ) {
124                $this->api->generate_new_wallet( $xpub_after, $this->id );
125                $this->api->generate_new_addresses_for_wallet( $xpub_after, 2 );
126            }
127
128            // TODO: maybe mark the previous xpub's wallet as "inactive". (although it could be in use in another instance of the gateway).
129        }
130
131        return $is_processed;
132    }
133
134    /**
135     * Initialize Gateway Settings Form Fields
136     *
137     * @see WC_Settings_API::init_form_fields()
138     *
139     * @return void
140     */
141    public function init_form_fields() {
142
143        $settings_fields = array(
144
145            'enabled'      => array(
146                'title'   => __( 'Enable/Disable', 'bh-wp-bitcoin-gateway' ),
147                'type'    => 'checkbox',
148                'label'   => __( 'Enable Bitcoin Payment', 'bh-wp-bitcoin-gateway' ),
149                'default' => 'yes',
150            ),
151
152            'title'        => array(
153                'title'       => __( 'Title', 'bh-wp-bitcoin-gateway' ),
154                'type'        => 'text',
155                'description' => __( 'The payment method title the customer sees during checkout.', 'bh-wp-bitcoin-gateway' ),
156                'default'     => __( 'Bitcoin', 'bh-wp-bitcoin-gateway' ),
157                'desc_tip'    => false,
158            ),
159
160            'description'  => array(
161                'title'       => __( 'Description', 'bh-wp-bitcoin-gateway' ),
162                'type'        => 'text',
163                'description' => __( 'Text the customer will see when the gateway is chosen at checkout.', 'bh-wp-bitcoin-gateway' ),
164                'default'     => __( 'Pay quickly and easily with Bitcoin', 'bh-wp-bitcoin-gateway' ),
165                'desc_tip'    => false,
166            ),
167
168            'xpub'         => array(
169                'title'       => __( 'Master Public Key', 'bh-wp-bitcoin-gateway' ),
170                'type'        => 'text',
171                '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' ),
172                'default'     => '',
173                'desc_tip'    => false,
174            ),
175
176            'price_margin' => array(
177                'title'             => __( 'price-margin', 'bh-wp-bitcoin-gateway' ),
178                'type'              => 'number',
179                'description'       => __( 'A percentage shortfall from the shown price which will be accepted, to allow for volatility.', 'bh-wp-bitcoin-gateway' ),
180                'default'           => '2',
181                'custom_attributes' => array(
182                    'min'  => 0,
183                    'max'  => 100,
184                    'step' => 1,
185                ),
186                'desc_tip'          => false,
187            ),
188        );
189
190        /**
191         * Let's get some products, filter to one that can be purchased, then use it to link to the checkout so
192         * the admin can see what it will all look like.
193         *
194         * @var \WC_Product[] $products
195         */
196        $products = wc_get_products(
197            array(
198                'status' => 'publish',
199                'limit'  => 10,
200            )
201        );
202        $products = array_filter(
203            $products,
204            function ( \WC_Product $product ): bool {
205                return $product->is_purchasable();
206            }
207        );
208        if ( ! empty( $products ) ) {
209            $a_product = array_pop( $products );
210
211            $checkout_url                                   = add_query_arg(
212                array(
213                    'add-to-cart'     => $a_product->get_id(),
214                    'payment_gateway' => 'bitcoin_gateway',
215                ),
216                wc_get_checkout_url()
217            );
218            $settings_fields['description']['description'] .= ' <a href="' . esc_url( $checkout_url ) . '">View checkout</a>.';
219        }
220
221        $saved_xpub = $this->plugin_settings->get_master_public_key( $this->id );
222        if ( ! empty( $saved_xpub ) ) {
223            $settings_fields['xpub']['description'] = '<a href="' . esc_url( admin_url( 'edit.php?post_type=bh-bitcoin-address' ) ) . '">View addresses</a>';
224        }
225
226        $settings_fields['price_margin']['description'] .= __( 'See: ', 'bh-wp-bitcoin-gateway' ) . '<a href="https://buybitcoinworldwide.com/volatility-index/" target="_blank">Bitcoin Volatility</a>.';
227
228        $log_levels        = array( 'none', LogLevel::ERROR, LogLevel::WARNING, LogLevel::NOTICE, LogLevel::INFO, LogLevel::DEBUG );
229        $log_levels_option = array();
230        foreach ( $log_levels as $log_level ) {
231            $log_levels_option[ $log_level ] = ucfirst( $log_level );
232        }
233
234        $settings_fields['log_level'] = array(
235            'title'       => __( 'Log Level', 'text-domain' ),
236            'label'       => __( 'Enable Logging', 'text-domain' ),
237            'type'        => 'select',
238            'options'     => $log_levels_option,
239            '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>',
240            'desc_tip'    => false,
241            'default'     => 'info',
242        );
243
244        $this->form_fields = apply_filters( 'wc_gateway_bitcoin_form_fields', $settings_fields, $this->id );
245    }
246
247
248    /**
249     * Returns false when the gateway is not configured / has no addresses to use.
250     *
251     * @see WC_Payment_Gateways::get_available_payment_gateways()
252     *
253     * @return bool
254     */
255    public function is_available() {
256
257        if ( is_null( $this->api ) ) {
258            return false;
259        }
260
261        if ( is_null( $this->is_available ) ) {
262            $result             = parent::is_available() && $this->api->is_fresh_address_available_for_gateway( $this );
263            $this->is_available = $result;
264        } else {
265            $result = $this->is_available;
266        }
267
268        return $result;
269    }
270
271    /**
272     * Process the payment and return the result.
273     *
274     * @param int $order_id The id of the order being paid.
275     *
276     * @return array{result:string, redirect:string}
277     * @throws Exception Throws an exception when no address is available (which is caught by WooCommerce and displayed at checkout).
278     */
279    public function process_payment( $order_id ) {
280
281        $order = wc_get_order( $order_id );
282
283        if ( ! ( $order instanceof WC_Order ) ) {
284            // This should never happen.
285            throw new Exception( __( 'Error creating order.', 'bh-wp-bitcoin-gateway' ) );
286        }
287
288        if ( is_null( $this->api ) ) {
289            throw new Exception( __( 'API unavailable for new Bitcoin gateway order.', 'bh-wp-bitcoin-gateway' ) );
290        }
291
292        $api = $this->api;
293
294        /**
295         * There should never really be an exception here, since the availability of a fresh address was checked before
296         * offering the option to pay by Bitcoin.
297         *
298         * @see Bitcoin_Gateway::is_available()
299         */
300        try {
301            /**
302             *
303             * @see Order::BITCOIN_ADDRESS_META_KEY
304             * @see Bitcoin_Address::get_raw_address()
305             */
306            $btc_address = $api->get_fresh_address_for_order( $order );
307        } catch ( Exception $e ) {
308            $this->logger->error( $e->getMessage(), array( 'exception' => $e ) );
309            throw new Exception( 'Unable to find Bitcoin address to send to. Please choose another payment method.' );
310        }
311
312        // Record the exchange rate at the time the order was placed.
313        $order->add_meta_data( Order::EXCHANGE_RATE_AT_TIME_OF_PURCHASE_META_KEY, $api->get_exchange_rate( $order->get_currency() ) );
314
315        $btc_total = $api->convert_fiat_to_btc( $order->get_currency(), $order->get_total() );
316
317        $order->add_meta_data( Order::ORDER_TOTAL_BITCOIN_AT_TIME_OF_PURCHASE_META_KEY, $btc_total );
318
319        // Mark as on-hold (we're awaiting the payment).
320        /* translators: %F: The order total in BTC */
321        $order->update_status( 'on-hold', sprintf( __( 'Awaiting Bitcoin payment of %F to address: ', 'bh-wp-bitcoin-gateway' ), $btc_total ) . '<a target="_blank" href="https://www.blockchain.com/btc/address/' . $btc_address->get_raw_address() . "\">{$btc_address->get_raw_address()}</a>.\n\n" );
322
323        $order->save();
324
325        // Reduce stock levels.
326        wc_reduce_stock_levels( $order_id );
327
328        // Remove cart.
329        WC()->cart->empty_cart();
330
331        // Return thankyou redirect.
332        return array(
333            'result'   => 'success',
334            'redirect' => $this->get_return_url( $order ),
335        );
336    }
337
338    /**
339     * Returns the configured xpub for the gateway, so new addresses can be generated.
340     *
341     * @used-by API::generate_new_addresses_for_wallet()
342     *
343     * @return string
344     */
345    public function get_xpub(): string {
346        return $this->settings['xpub'];
347    }
348
349    /**
350     * Price margin is the allowable difference between the amount received and the amount expected.
351     *
352     * @used-by API::get_order_details()
353     *
354     * @return float
355     */
356    public function get_price_margin_percent(): float {
357        return floatval( $this->settings['price_margin'] ?? 2.0 );
358    }
359}