Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.42% covered (warning)
65.42%
70 / 107
16.67% covered (danger)
16.67%
2 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
API_WooCommerce
65.42% covered (warning)
65.42%
70 / 107
16.67% covered (danger)
16.67%
2 / 12
67.21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_bitcoin_gateway
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 get_bitcoin_gateways
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 is_order_has_bitcoin_gateway
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 assign_unused_address_to_order
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
2
 get_fresh_address_for_gateway
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 is_unused_address_available_for_gateway
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 get_order_details
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 check_order_for_payment
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 mark_order_paid
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 add_order_note_for_transactions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_formatted_order_details
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @package brianhenryie/bh-wp-bitcoin-gateway
4 */
5
6namespace BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce;
7
8use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs_Scheduler_Interface;
9use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address;
10use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception;
11use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Transaction_Interface;
12use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Bitcoin_Wallet_Service;
13use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Payment_Service;
14use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Results\Check_Address_For_Payment_Service_Result;
15use BrianHenryIE\WP_Bitcoin_Gateway\API_Interface;
16use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\MoneyMismatchException;
17use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money;
18use BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce\Helpers\WC_Order_Meta_Helper;
19use BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce\Model\WC_Bitcoin_Order;
20use DateInterval;
21use DateMalformedStringException;
22use DateTimeImmutable;
23use Psr\Log\LoggerAwareInterface;
24use Psr\Log\LoggerAwareTrait;
25use Psr\Log\LoggerInterface;
26use WC_Order;
27use WC_Payment_Gateway;
28use WC_Payment_Gateways;
29
30/**
31 * Implements API_WooCommerce_Interface
32 */
33class API_WooCommerce implements API_WooCommerce_Interface, LoggerAwareInterface {
34    use LoggerAwareTrait;
35
36    /**
37     * Constructor
38     *
39     * @param API_Interface                       $api Main plugin API.
40     * @param Bitcoin_Wallet_Service              $wallet_service For creating/fetching wallets and addresses.
41     * @param Payment_Service                     $payment_service For getting transaction data/checking for payments.
42     * @param WC_Order_Meta_Helper                $order_meta_helper Meta helper.
43     * @param Background_Jobs_Scheduler_Interface $background_jobs_scheduler When an order is placed, schedule a payment check.
44     * @param LoggerInterface                     $logger PSR logger.
45     */
46    public function __construct(
47        protected API_Interface $api,
48        protected Bitcoin_Wallet_Service $wallet_service,
49        protected Payment_Service $payment_service,
50        protected WC_Order_Meta_Helper $order_meta_helper,
51        protected Background_Jobs_Scheduler_Interface $background_jobs_scheduler,
52        LoggerInterface $logger,
53    ) {
54        $this->setLogger( $logger );
55    }
56
57    /**
58     * Check a gateway id and determine is it an instance of this gateway type.
59     * Used on thank you page to return early.
60     *
61     * @used-by Thank_You::print_instructions()
62     *
63     * @param string|non-empty-string $gateway_id The id of the gateway to check.
64     */
65    public function is_bitcoin_gateway( string $gateway_id ): bool {
66        if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) || ! class_exists( WC_Payment_Gateway::class ) ) {
67            return false;
68        }
69        if ( empty( $gateway_id ) ) {
70            return false;
71        }
72
73        $bitcoin_gateways = $this->get_bitcoin_gateways();
74
75        $gateway_ids = array_map(
76            fn( WC_Payment_Gateway $gateway ): string => $gateway->id,
77            $bitcoin_gateways
78        );
79
80        return in_array( $gateway_id, $gateway_ids, true );
81    }
82
83    /**
84     * Get all instances of the Bitcoin gateway.
85     * (typically there is only one).
86     *
87     * @return array<string, Bitcoin_Gateway>
88     */
89    public function get_bitcoin_gateways(): array {
90        // The second check here is because on the first page load after deleting a plugin, it is still in the active plugins list.
91        if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) || ! class_exists( WC_Payment_Gateways::class ) ) {
92            return array();
93        }
94
95        $payment_gateways = WC_Payment_Gateways::instance()->payment_gateways();
96        $bitcoin_gateways = array();
97        foreach ( $payment_gateways as $gateway ) {
98            if ( $gateway instanceof Bitcoin_Gateway ) {
99                $bitcoin_gateways[ $gateway->id ] = $gateway;
100            }
101        }
102
103        return $bitcoin_gateways;
104    }
105
106    /**
107     * Given an order id, determine is the order's gateway an instance of this Bitcoin gateway.
108     *
109     * @see https://github.com/BrianHenryIE/bh-wp-duplicate-payment-gateways
110     *
111     * @param int|string $order_id The id of the (presumed) WooCommerce order to check.
112     */
113    public function is_order_has_bitcoin_gateway( int|string $order_id ): bool {
114        if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) || ! function_exists( 'wc_get_order' ) ) {
115            return false;
116        }
117
118        $order = wc_get_order( $order_id );
119
120        if ( ! ( $order instanceof WC_Order ) ) {
121            // Unlikely.
122            return false;
123        }
124
125        $payment_gateway_id = $order->get_payment_method();
126
127        if ( ! $this->is_bitcoin_gateway( $payment_gateway_id ) ) {
128            // Exit, this isn't for us.
129            return false;
130        }
131
132        return true;
133    }
134
135    /**
136     * Fetches an unused address from the cache, or generates a new one if none are available.
137     *
138     * Called inside the "place order" function, then it can throw an exception.
139     * if there's a problem and the user can immediately choose another payment method.
140     *
141     * Load our already generated fresh list.
142     * Check with a remote API that it has not been used.
143     * Save it to the order metadata.
144     * Save it locally as used.
145     * Maybe schedule more address generation.
146     * Return it to be used in an order.
147     *
148     * @used-by Bitcoin_Gateway::process_payment()
149     *
150     * @param WC_Order $order The order that will use the address.
151     * @param Money    $btc_total The required value of Bitcoin after which this order will be considered paid.
152     *
153     * @return Bitcoin_Address
154     * @throws BH_WP_Bitcoin_Gateway_Exception When no Bitcoin addresses are available or the address cannot be assigned to the order.
155     */
156    public function assign_unused_address_to_order( WC_Order $order, Money $btc_total ): Bitcoin_Address {
157        $this->logger->debug( 'Get fresh address for `shop_order:{order_id}`', array( 'order_id' => $order->get_id() ) );
158
159        $btc_address = $this->get_fresh_address_for_gateway( $this->get_bitcoin_gateways()[ $order->get_payment_method() ] );
160
161        if ( is_null( $btc_address ) ) {
162            throw new BH_WP_Bitcoin_Gateway_Exception( 'No Bitcoin addresses available.' );
163        }
164
165        $refreshed_address = $this->wallet_service->assign_order_to_bitcoin_payment_address(
166            address: $btc_address,
167            integration_id: WooCommerce_Integration::class,
168            order_id: $order->get_id(),
169            btc_total: $btc_total
170        );
171
172        $this->order_meta_helper->set_raw_address( wc_order: $order, payment_address: $btc_address, save_now: true );
173
174        $this->logger->info(
175            'Assigned `bh-bitcoin-address:{post_id}` {address} to `shop_order:{order_id}`.',
176            array(
177                'post_id'  => $btc_address->get_post_id(),
178                'address'  => $btc_address->get_raw_address(),
179                'order_id' => $order->get_id(),
180            )
181        );
182
183        // Now that the address is assigned, schedule a job to check it for payment transactions.
184        $this->background_jobs_scheduler->schedule_single_check_assigned_addresses_for_transactions(
185            date_time: new DateTimeImmutable( 'now' )->add( new DateInterval( 'PT15M' ) )
186        );
187
188        return $refreshed_address;
189    }
190
191    /**
192     * Get an unused payment addresses for a specific payment gateway's wallet.
193     *
194     * TODO: this should Makes a remote API call if the address has not been recently checked.
195     *
196     * @param Bitcoin_Gateway $gateway The Bitcoin payment gateway to get addresses for.
197     *
198     * @throws BH_WP_Bitcoin_Gateway_Exception When the wallet cannot be created/retrieved or unused addresses cannot be generated.
199     */
200    public function get_fresh_address_for_gateway( Bitcoin_Gateway $gateway ): ?Bitcoin_Address {
201
202        if ( empty( $gateway->get_xpub() ) ) {
203            $this->logger->debug( "No master public key set on gateway {$gateway->id}", array( 'gateway' => $gateway ) );
204            return null;
205        }
206
207        $wallet_result = $this->wallet_service->get_or_save_wallet_for_xpub( $gateway->get_xpub() );
208
209        $result = $this->api->ensure_unused_addresses_for_wallet_synchronously( $wallet_result->wallet, 1 );
210
211        $unused_addresses = $result->get_unused_addresses();
212
213        return empty( $unused_addresses ) ? null : $unused_addresses[ array_key_first( $unused_addresses ) ];
214    }
215
216    /**
217     * Check do we have at least one address already generated and ready to use. Does not generate addresses.
218     *
219     * @param Bitcoin_Gateway $gateway The gateway id the address is for.
220     *
221     * @used-by Bitcoin_Gateway::is_available()
222     *
223     * @return bool
224     * @throws BH_WP_Bitcoin_Gateway_Exception When the wallet lookup fails or the address repository cannot be queried.
225     */
226    public function is_unused_address_available_for_gateway( Bitcoin_Gateway $gateway ): bool {
227
228        if ( is_null( $gateway->get_xpub() ) ) {
229            return false;
230        }
231
232        $get_wallet_for_xpub_service_result = $this->wallet_service->get_or_save_wallet_for_xpub( $gateway->get_xpub() );
233
234        // This will schedule a job if there are none.
235        return $this->api->is_unused_address_available_for_wallet( $get_wallet_for_xpub_service_result->wallet );
236    }
237
238    /**
239     * Get the current status of the order's payment.
240     *
241     * As a really detailed array for printing.
242     *
243     * `array{btc_address:string, bitcoin_total:Money, btc_price_at_at_order_time:string, transactions:array<string, TransactionArray>, btc_exchange_rate:string, last_checked_time:DateTimeInterface, btc_amount_received:string, order_status_before:string}`
244     *
245     * @param WC_Order $wc_order The WooCommerce order to check.
246     *
247     * @return WC_Bitcoin_Order
248     * @throws BH_WP_Bitcoin_Gateway_Exception When the order has no Bitcoin address or blockchain API queries fail during refresh.
249     */
250    public function get_order_details( WC_Order $wc_order ): WC_Bitcoin_Order {
251
252        /** @var ?string $assigned_payment_address */
253        $assigned_payment_address = $this->order_meta_helper->get_raw_payment_address( $wc_order );
254
255        if ( is_null( $assigned_payment_address ) ) {
256            // If this were to happen, it should be possible to look up which address is associated with this order id.
257            throw new BH_WP_Bitcoin_Gateway_Exception( 'No Bitcoin payment address found for order.' );
258        }
259        $bitcoin_address = $this->wallet_service->get_saved_address_by_bitcoin_payment_address( $assigned_payment_address );
260
261        $transaction_ids = $bitcoin_address->get_tx_ids();
262
263        $transactions = null;
264        if ( ! is_null( $transaction_ids ) ) {
265            $transactions = $this->payment_service->get_saved_transactions(
266                transaction_post_ids: array_keys( $transaction_ids )
267            );
268        }
269
270        $bitcoin_order = new WC_Bitcoin_Order(
271            wc_order: $wc_order,
272            payment_address: $bitcoin_address,
273            transactions: $transactions,
274            logger: $this->logger
275        );
276
277        return $bitcoin_order;
278    }
279
280    /**
281     * Perform a remote check for transactions and save new details to the order.
282     *
283     * TODO: mempool.
284     *
285     * @param WC_Order $order The WC_Order order to refresh.
286     *
287     * @throws BH_WP_Bitcoin_Gateway_Exception When blockchain API queries fail or transaction data cannot be updated.
288     * @throws MoneyMismatchException If somehow we attempt to perform calculations between two different currencies.
289     * @throws DateMalformedStringException If the saved transaction data has been modified in the db and cannot be deserialized.
290     */
291    public function check_order_for_payment( WC_Order $order ): void {
292
293        $bitcoin_order = $this->get_order_details( $order );
294
295        $bitcoin_address        = $bitcoin_order->get_address();
296        $confirmed_value_before = $bitcoin_address->get_amount_received();
297
298        $check_address_for_payment_result = $this->api->check_address_for_payment( $bitcoin_address );
299
300        /**
301         * By this point 0/1/both {@see Order::new_transactions_seen()} or {@see Order::payment_received()} will have
302         * been called.
303         *
304         * @see self::add_order_note_for_transactions()
305         * @see self::mark_order_paid()
306         *
307         * Maybe `remove_action` before the user initiated synchronous call so better data can be returned?
308         *
309         * `remove_all_actions('bh_wp_bitcoin_gateway_new_transactions_seen')`.
310         */
311    }
312
313    /**
314     * Mark the order as paid using the latest transaction's id as the order transaction id. Save the amount
315     * received to the order meta.
316     *
317     * @see WC_Order::payment_complete()
318     *
319     * @param WC_Order                                 $wc_order The order in question.
320     * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The details of the requirements + transactions.
321     * @throws BH_WP_Bitcoin_Gateway_Exception If the amount is invalid.
322     */
323    public function mark_order_paid(
324        WC_Order $wc_order,
325        Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result,
326    ): void {
327
328        if ( $check_address_for_payment_service_result->confirmed_received->isNegativeOrZero() ) {
329            // This should never happen.
330            throw new BH_WP_Bitcoin_Gateway_Exception( 'Invalid amount_received: ' . $check_address_for_payment_service_result->confirmed_received->__toString() . ' is negative or zero.' );
331        }
332
333        $this->order_meta_helper->set_confirmed_amount_received(
334            wc_order: $wc_order,
335            updated_confirmed_value: $check_address_for_payment_service_result->confirmed_received,
336            save_now: false
337        );
338
339        /**
340         * We know there must be at least one transaction if we've summed them to the required amount!
341         *
342         * @var Transaction_Interface $last_transaction
343         */
344        $last_transaction = array_last( $check_address_for_payment_service_result->all_transactions );
345        $wc_order->payment_complete( $last_transaction->get_txid() );
346        $wc_order->save();
347
348        $this->logger->info( '`shop_order:{order_id}` has been marked paid.', array( 'order_id' => $wc_order->get_id() ) );
349    }
350
351    /**
352     * Add a note saying "New transactions seen", linking to the details.
353     *
354     * TODO: show ~"unconfirmed total =..., confirmed total = ...".
355     *
356     * @used-by Order::new_transactions_seen()
357     * @used-by self::refresh_order()
358     *
359     * @param WC_Order                     $order The WooCommerce order to record the new transactions for.
360     * @param array<Transaction_Interface> $new_transactions The transactions.
361     */
362    #[\Deprecated( message: "This function signature is expected to change to pass data for totals. Please don't use it directly." )]
363    public function add_order_note_for_transactions( WC_Order $order, array $new_transactions ): void {
364        $note = Transaction_Formatter::get_order_note( $new_transactions );
365        $order->add_order_note( $note );
366    }
367
368    /**
369     * Get order details for printing in HTML templates.
370     *
371     * Returns an array of:
372     * * HTML formatted values
373     * * raw values that are known to be used in the templates
374     * * objects the values are from
375     *
376     * @param WC_Order $order The WooCommerce order object to update.
377     *
378     * @uses API_WooCommerce_Interface::get_order_details()
379     * @see  Details_Formatter
380     *
381     * @return array<string, mixed>
382     *
383     * @throws BH_WP_Bitcoin_Gateway_Exception When order details cannot be retrieved or formatted due to missing address or API failures.
384     */
385    public function get_formatted_order_details( WC_Order $order ): array {
386
387        $order_details = $this->get_order_details( $order );
388
389        $formatted = new Details_Formatter( $order_details, $this->order_meta_helper );
390
391        // HTML formatted data.
392        $result = $formatted->to_array();
393
394        // Raw data. TODO: convert `::get_btc_total_price(): Money`, use typed class with all strings.
395        $result['btc_total']           = $this->order_meta_helper->get_btc_total_price( $order );
396        $result['btc_exchange_rate']   = $this->order_meta_helper->get_exchange_rate( $order );
397        $result['btc_address']         = $order_details->get_address()->get_raw_address();
398        $result['transactions']        = $this->api->get_saved_transactions( $order_details->get_address() );
399        $result['btc_amount_received'] = $order_details->get_address()->get_amount_received() ?? 'unknown';
400
401        // Objects.
402        $result['order']         = $order;
403        $result['bitcoin_order'] = $order_details;
404
405        return $result;
406    }
407}