Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
65.42% |
70 / 107 |
|
16.67% |
2 / 12 |
CRAP | |
0.00% |
0 / 1 |
| API_WooCommerce | |
65.42% |
70 / 107 |
|
16.67% |
2 / 12 |
67.21 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| is_bitcoin_gateway | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
4.13 | |||
| get_bitcoin_gateways | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
5.05 | |||
| is_order_has_bitcoin_gateway | |
66.67% |
6 / 9 |
|
0.00% |
0 / 1 |
5.93 | |||
| assign_unused_address_to_order | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
2 | |||
| get_fresh_address_for_gateway | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| is_unused_address_available_for_gateway | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| get_order_details | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
3.00 | |||
| check_order_for_payment | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| mark_order_paid | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
| add_order_note_for_transactions | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| get_formatted_order_details | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @package brianhenryie/bh-wp-bitcoin-gateway |
| 4 | */ |
| 5 | |
| 6 | namespace BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce; |
| 7 | |
| 8 | use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs_Scheduler_Interface; |
| 9 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address; |
| 10 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception; |
| 11 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Transaction_Interface; |
| 12 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Bitcoin_Wallet_Service; |
| 13 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Payment_Service; |
| 14 | use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Results\Check_Address_For_Payment_Service_Result; |
| 15 | use BrianHenryIE\WP_Bitcoin_Gateway\API_Interface; |
| 16 | use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\MoneyMismatchException; |
| 17 | use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money; |
| 18 | use BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce\Helpers\WC_Order_Meta_Helper; |
| 19 | use BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce\Model\WC_Bitcoin_Order; |
| 20 | use DateInterval; |
| 21 | use DateMalformedStringException; |
| 22 | use DateTimeImmutable; |
| 23 | use Psr\Log\LoggerAwareInterface; |
| 24 | use Psr\Log\LoggerAwareTrait; |
| 25 | use Psr\Log\LoggerInterface; |
| 26 | use WC_Order; |
| 27 | use WC_Payment_Gateway; |
| 28 | use WC_Payment_Gateways; |
| 29 | |
| 30 | /** |
| 31 | * Implements API_WooCommerce_Interface |
| 32 | */ |
| 33 | class 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 | } |