Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.33% covered (danger)
19.33%
29 / 150
11.76% covered (danger)
11.76%
2 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
API
19.33% covered (danger)
19.33%
29 / 150
11.76% covered (danger)
11.76%
2 / 17
1156.70
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
 get_exchange_rate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 convert_fiat_to_btc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 update_exchange_rate
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
2.01
 get_or_save_wallet_for_master_public_key
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 is_unused_address_available_for_wallet
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 generate_new_addresses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 ensure_unused_addresses
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
210
 ensure_unused_addresses_for_wallet_synchronously
n/a
0 / 0
n/a
0 / 0
1
 generate_new_addresses_for_wallet
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 check_new_addresses_for_transactions
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
2.75
 check_addresses_for_transactions
52.00% covered (warning)
52.00%
13 / 25
0.00% covered (danger)
0.00%
0 / 1
9.98
 check_assigned_addresses_for_payment
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 check_address_for_payment
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_fire_new_transactions_seen_action
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_mark_address_as_paid
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 maybe_fire_mark_address_paid_action
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 get_saved_transactions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Main plugin functions for:
4 * * checking is a gateway a Bitcoin gateway
5 * * generating new wallets
6 * * converting fiat<->BTC
7 * * generating/getting new addresses for orders
8 * * checking addresses for transactions
9 * * getting order details for display
10 *
11 * @package    brianhenryie/bh-wp-bitcoin-gateway
12 *
13 * - TODO After x unpaid time, mark unpaid orders as failed/cancelled.
14 * - TODO: There should be a global cap on how long an address can be assigned without payment. Not something to handle in this class
15 * – TODO: hook into post_status changes (+count) to decide to schedule? Or call directly from API class when it assigns an Address to an Order?
16 */
17
18namespace BrianHenryIE\WP_Bitcoin_Gateway\API;
19
20use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\API_Background_Jobs_Interface;
21use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs_Actions_Interface;
22use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs_Scheduler_Interface;
23use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Payments\Bitcoin_Transaction;
24use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Update_Exchange_Rate_Result;
25use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address_Status;
26use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Check_Address_For_Payment_Result;
27use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\Rate_Limit_Exception;
28use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Addresses_Generation_Result;
29use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception;
30use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Check_Assigned_Addresses_For_Transactions_Result;
31use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Ensure_Unused_Addresses_Result;
32use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Mark_Address_As_Paid_Result;
33use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Results\Wallet_Generation_Result;
34use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Bitcoin_Wallet_Service;
35use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Exchange_Rate_Service;
36use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Payment_Service;
37use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Results\Check_Address_For_Payment_Service_Result;
38use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Currency;
39use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\MoneyMismatchException;
40use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\UnknownCurrencyException;
41use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money;
42use BrianHenryIE\WP_Bitcoin_Gateway\Action_Scheduler\Background_Jobs_Actions_Handler;
43use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address;
44use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Wallet;
45use BrianHenryIE\WP_Bitcoin_Gateway\API_Interface;
46use BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce\Bitcoin_Gateway;
47use BrianHenryIE\WP_Bitcoin_Gateway\Integrations\WooCommerce\Checkout;
48use BrianHenryIE\WP_Bitcoin_Gateway\Settings_Interface;
49use DateInterval;
50use DateTimeImmutable;
51use Exception;
52use JsonException;
53use Psr\Log\LoggerAwareTrait;
54use Psr\Log\LoggerInterface;
55
56/**
57 * Main API implementation for the Bitcoin Gateway plugin.
58 *
59 * Handles core functionality including exchange rate conversion between fiat and BTC,
60 * wallet and address generation from master public keys (xpub/ypub/zpub), checking
61 * Bitcoin addresses for incoming transactions via blockchain APIs, and managing payment
62 * confirmation workflows for assigned addresses.
63 */
64class API implements API_Interface, API_Background_Jobs_Interface {
65    use LoggerAwareTrait;
66
67    /**
68     * Constructor
69     *
70     * @param Settings_Interface                  $settings The plugin settings.
71     * @param Exchange_Rate_Service               $exchange_rate_service Client for fetching current BTC exchange rates from external APIs (e.g., Bitfinex, Bitstamp).
72     * @param Bitcoin_Wallet_Service              $wallet_service Generating wallets and payment addresses.
73     * @param Payment_Service                     $payment_service Service for confirming payments.
74     * @param Background_Jobs_Scheduler_Interface $background_jobs_scheduler Scheduler for queuing recurring tasks like checking addresses for payments and generating new addresses via Action Scheduler.
75     * @param LoggerInterface                     $logger A PSR logger for recording errors, warnings, and debug information.
76     */
77    public function __construct(
78        protected Settings_Interface $settings,
79        protected Exchange_Rate_Service $exchange_rate_service,
80        protected Bitcoin_Wallet_Service $wallet_service,
81        protected Payment_Service $payment_service,
82        protected Background_Jobs_Scheduler_Interface $background_jobs_scheduler,
83        LoggerInterface $logger,
84    ) {
85        $this->setLogger( $logger );
86    }
87
88    /**
89     * Return the cached exchange rate, or fetch it.
90     * Cache for one hour.
91     *
92     * Value of 1 BTC.
93     *
94     * @param Currency $currency The fiat currency to get the BTC exchange rate for (e.g., USD, EUR, GBP).
95     *
96     * @throws BH_WP_Bitcoin_Gateway_Exception When the exchange rate API returns invalid data or the currency is not supported.
97     */
98    public function get_exchange_rate( Currency $currency ): ?Money {
99        return $this->exchange_rate_service->get_exchange_rate( $currency );
100    }
101
102    /**
103     * Get the BTC value of another currency amount.
104     *
105     * Limited currency support: 'USD'|'EUR'|'GBP', maybe others.
106     *
107     * @param Money $fiat_amount The order total amount in fiat currency from the WooCommerce order (stored as a float string in order meta).
108     *
109     * @throws BH_WP_Bitcoin_Gateway_Exception When no exchange rate is available for the given currency.
110     */
111    public function convert_fiat_to_btc( Money $fiat_amount ): Money {
112        return $this->exchange_rate_service->convert_fiat_to_btc( $fiat_amount );
113    }
114
115    /**
116     * Update the exchange rate.
117     *
118     * Fetches and caches the current BTC exchange rate.
119     *
120     * @used-by Background_Jobs_Actions_Handler::update_exchange_rate()
121     * @throws UnknownCurrencyException If the currency code is not known by the brick/money library.
122     * @throws JsonException If the API response cannot be parsed.
123     * @throws BH_WP_Bitcoin_Gateway_Exception If the request fails or the response !== 2xx.
124     */
125    public function update_exchange_rate(): Update_Exchange_Rate_Result {
126
127        // If WooCommerce is not active, default to USD.
128        if ( function_exists( 'get_woocommerce_currency' ) ) {
129            $source        = 'woocommerce';
130            $currency_code = get_woocommerce_currency();
131        } else {
132            $source        = 'default-usd';
133            $currency_code = 'USD';
134        }
135
136        $currency              = Currency::of( $currency_code );
137        $updated_exchange_rate = $this->exchange_rate_service->update_exchange_rate( $currency );
138        $this->logger->debug( 'Exchange rate updated for {currency}.', array( 'currency' => $currency->getCurrencyCode() ) );
139
140        return new Update_Exchange_Rate_Result(
141            requested_exchange_rate_currency: $currency_code,
142            source: $source,
143            updated_exchange_rate: $updated_exchange_rate
144        );
145    }
146
147    /**
148     * Get or create a Wallet for the given master public key. Optionally, set the gateway id if the wallet is new.
149     *
150     * @param string                                              $xpub Bitcoin master public key.
151     * @param ?array{integration:class-string, gateway_id:string} $gateway_details Gateway id.
152     * @throws BH_WP_Bitcoin_Gateway_Exception If two wallets for the xpub exist, or if saving fails.
153     */
154    public function get_or_save_wallet_for_master_public_key( string $xpub, ?array $gateway_details = null ): Wallet_Generation_Result {
155        $result = $this->wallet_service->get_or_save_wallet_for_xpub( $xpub, $gateway_details );
156
157        if ( $result->is_new ) {
158            $this->background_jobs_scheduler->schedule_single_ensure_unused_addresses( $result->wallet );
159        }
160
161        return new Wallet_Generation_Result(
162            get_wallet_for_xpub_service_result: $result,
163            did_schedule_ensure_addresses: $result->is_new,
164        );
165    }
166
167    /**
168     * A local-only check to see if the wallet has any payment addresses whose status has been checked in the last ten
169     * minutes.
170     *
171     * @param Bitcoin_Wallet $wallet The wallet object we need an address to give to a customer.
172     */
173    public function is_unused_address_available_for_wallet( Bitcoin_Wallet $wallet ): bool {
174        $success = $this->wallet_service->has_unused_bitcoin_address( $wallet );
175
176        if ( ! $success ) {
177            $this->background_jobs_scheduler->schedule_single_ensure_unused_addresses( $wallet );
178        }
179
180        return $success;
181    }
182
183    /**
184     * If a wallet has fewer than 20 fresh addresses available, generate some more.
185     *
186     * @return Addresses_Generation_Result[]
187     * @see API_Interface::generate_new_addresses()
188     * @used-by CLI::generate_new_addresses()
189     * @used-by Background_Jobs_Actions_Handler::generate_new_addresses()
190     */
191    public function generate_new_addresses(): array {
192        return $this->wallet_service->generate_new_addresses();
193    }
194
195    /**
196     * Check that there are two addresses generated and unused for every wallet (or specific wallet/number).
197     *
198     * TODO: This should be checking the address's last modified time and not making remote API calls if it was recently checked.
199     *
200     * TODO: If a store has 100 orders/minute, this should still only check each address once every ten minutes, since
201     * until a new block is mined, the result won't change. TODO: mempool?
202     *
203     * Payment addresses may be used outside WordPress and if we were to reuse those addresses, confirming the payment
204     * can't be done confidently. (TODO: still only consider transactions made after the address is assigned to an order).
205     *
206     * @used-by Background_Jobs_Actions_Interface::ensure_unused_addresses()
207     *
208     * @param int              $required_count The number of addresses to be sure have not been used yet. There is no real point checking more than 2, especially if using free APIs with low rate limits.
209     * @param Bitcoin_Wallet[] $wallets The Bitcoin Wallets to check. When on a recurring schedule, check all; when at checkout using a specific wallet, only check that one.
210     *
211     * @return array<string, Ensure_Unused_Addresses_Result> array<wallet_xpub: Ensure_Unused_Addresses_Result>
212     *
213     * @throws Rate_Limit_Exception TODO: I think this should be caught before here, the job rescheduled and that fact recorded in function response (Ensure_Unused_Addresses_Result probably isn't enough, so).
214     */
215    public function ensure_unused_addresses( int $required_count = 2, array $wallets = array() ): array {
216        // TODO: write a test to see is wallet status being used correctly, then add filter: `Bitcoin_Wallet_Status::ACTIVE`.
217        $wallets = ! empty( $wallets ) ? $wallets : $this->wallet_service->get_all_wallets();
218
219        /** @var array<int, array<Bitcoin_Address>> $assumed_existing_unused_addresses Wallet post id:Bitcoin_Address[] */
220        $assumed_existing_unused_addresses = array();
221        /** @var array<int, array<Bitcoin_Address>> $actual_unused_addresses_by_wallet Wallet post id:Bitcoin_Address[] */
222        $actual_unused_addresses_by_wallet = array();
223        /** @var array<int, array<Bitcoin_Address>> $unexpectedly_used_addresses_by_wallet Wallet post id:Bitcoin_Address[] */
224        $unexpectedly_used_addresses_by_wallet = array();
225        /** @var array<int, array<Bitcoin_Address>> $new_addresses_by_wallet Wallet post id:Bitcoin_Address[] */
226        $new_addresses_by_wallet = array();
227        foreach ( $wallets as $wallet ) {
228            $assumed_existing_unused_addresses[ $wallet->get_post_id() ]     = array();
229            $actual_unused_addresses_by_wallet[ $wallet->get_post_id() ]     = array();
230            $unexpectedly_used_addresses_by_wallet[ $wallet->get_post_id() ] = array();
231            $new_addresses_by_wallet[ $wallet->get_post_id() ]               = array();
232        }
233
234        // Sort by last updated (checked) and get two per wallet.
235        // TODO: check the modified time and assume any that were checked in the past ten minutes are still valid (since no new block has been completed since).
236        $unused_addresses = $this->wallet_service->get_unused_bitcoin_addresses();
237
238        /**
239         * @param array<int, array<Bitcoin_Address>> $unused_addresses_by_wallet
240         * @param int $required_count
241         */
242        $all_wallets_have_enough_addresses_fn = ( fn( array $unused_addresses_by_wallet, int $required_count ): bool => array_reduce(
243            $unused_addresses_by_wallet,
244            /**
245                 * This is definitely safe to ignore.
246                 * "Parameter #2 $callback of function array_reduce expects callable(bool, mixed): bool, Closure(bool, array): bool given".
247                 *
248                 * @phpstan-ignore argument.type
249                 */
250                fn( bool $carry, array $addresses ): bool => $carry && count( $addresses ) >= $required_count,
251            true
252        ) );
253
254        foreach ( $unused_addresses as $address ) {
255            $address_wallet_id = $address->get_wallet_parent_post_id();
256            if ( count( $actual_unused_addresses_by_wallet[ $address_wallet_id ] ) >= $required_count ) {
257                continue;
258            }
259
260            // TODO: Should we index by anything?
261            $assumed_existing_unused_addresses[ $address_wallet_id ][] = $address;
262
263            if ( $address->was_checked_recently() ) {
264                $actual_unused_addresses_by_wallet[ $address_wallet_id ][] = $address;
265                continue;
266            }
267
268            // TODO: handle rate limits.
269            $address_transactions_result = $this->payment_service->update_address_transactions( $address );
270            $this->wallet_service->update_address_transactions_posts( $address, $address_transactions_result->all_transactions );
271
272            if ( $address_transactions_result->is_unused() ) {
273                $actual_unused_addresses_by_wallet[ $address_wallet_id ][] = $address;
274            } else {
275                $unexpectedly_used_addresses_by_wallet[ $address_wallet_id ][] = $address;
276
277                $this->wallet_service->set_payment_address_status(
278                    address: $address,
279                    status: Bitcoin_Address_Status::USED,
280                );
281
282                // TODO: log more.
283            }
284        }
285
286        // This could loop hundreds of time, e.g. you add a wallet that has been in use elsewhere and it has
287        // to check each used address until it finds an unused one.
288        while ( ! $all_wallets_have_enough_addresses_fn( $actual_unused_addresses_by_wallet, $required_count ) ) {
289            foreach ( $wallets as $wallet ) {
290                if ( count( $actual_unused_addresses_by_wallet[ $wallet->get_post_id() ] ) < $required_count ) {
291                    $address_generation_result = $this->generate_new_addresses_for_wallet( $wallet, 1 );
292                    $new_address               = $address_generation_result->new_addresses[ array_key_first( $address_generation_result->new_addresses ) ];
293
294                    $address_transactions_result = $this->payment_service->update_address_transactions( $new_address );
295                    $this->wallet_service->update_address_transactions_posts( $new_address, $address_transactions_result->all_transactions );
296
297                    $is_used_status = $address_transactions_result->is_unused() ? Bitcoin_Address_Status::UNUSED : Bitcoin_Address_Status::USED;
298
299                    $this->wallet_service->set_payment_address_status(
300                        address: $new_address,
301                        status: $is_used_status,
302                    );
303
304                    if ( $address_transactions_result->is_unused() ) {
305                        $actual_unused_addresses_by_wallet[ $wallet->get_post_id() ][] = $new_address;
306                        $new_addresses_by_wallet[ $wallet->get_post_id() ][]           = $new_address;
307                    }
308                }
309            }
310        }
311
312        /** @var array<string, Ensure_Unused_Addresses_Result> $result_by_wallet */
313        $result_by_wallet = array();
314
315        foreach ( $wallets as $wallet ) {
316            $result_by_wallet[ $wallet->get_xpub() ] = new Ensure_Unused_Addresses_Result(
317                wallet: $wallet,
318                assumed_existing_unused_addresses: $assumed_existing_unused_addresses[ $wallet->get_post_id() ],
319                actual_existing_unused_addresses: $actual_unused_addresses_by_wallet[ $wallet->get_post_id() ],
320                unexpectedly_used_addresses_by_wallet: $unexpectedly_used_addresses_by_wallet[ $wallet->get_post_id() ],
321                new_unused_addresses: $new_addresses_by_wallet[ $wallet->get_post_id() ],
322            );
323        }
324
325        return $result_by_wallet;
326    }
327
328    /**
329     * Ensure a specific wallet has the required number of verified unused addresses available.
330     *
331     * @used-by Checkout::ensure_one_address_for_payment()
332     * @see Bitcoin_Gateway::process_payment()
333     *
334     * @param Bitcoin_Wallet $wallet The wallet to generate unused addresses for by querying the blockchain to verify generated addresses have no transaction history.
335     * @param int            $required_count The minimum number of unused addresses that must be available for this wallet before returning.
336     */
337    public function ensure_unused_addresses_for_wallet_synchronously( Bitcoin_Wallet $wallet, int $required_count = 2 ): Ensure_Unused_Addresses_Result {
338        return $this->ensure_unused_addresses( $required_count, array( $wallet ) )[ $wallet->get_xpub() ];
339    }
340
341    /**
342     * Derive new Bitcoin addresses for a saved wallet.
343     *
344     * @param Bitcoin_Wallet $wallet The wallet to generate child addresses from using its master public key and current address index.
345     * @param int            $generate_count The number of sequential addresses to derive from the wallet's next available derivation path index. 20 is the standard lookahead for wallets.
346     *
347     * @throws BH_WP_Bitcoin_Gateway_Exception When address derivation fails or addresses cannot be saved to the database.
348     */
349    public function generate_new_addresses_for_wallet( Bitcoin_Wallet $wallet, int $generate_count = 2 ): Addresses_Generation_Result {
350        $address_generation_result = $this->wallet_service->generate_new_addresses_for_wallet( $wallet, $generate_count );
351
352        /**
353         * @see self::check_addresses_for_transactions()
354         */
355        $this->background_jobs_scheduler->schedule_single_ensure_unused_addresses( $wallet );
356
357        return $address_generation_result;
358    }
359
360    /**
361     * Check newly generated addresses with "unknown" status for incoming transactions.
362     *
363     * @used-by Background_Jobs_Actions_Handler::check_new_addresses_for_transactions()
364     *
365     * @return Check_Assigned_Addresses_For_Transactions_Result Result containing the count of addresses checked before rate limiting or completion.
366     * @throws Rate_Limit_Exception When the blockchain API's rate limit is exceeded, containing the reset time for rescheduling the job.
367     */
368    public function check_new_addresses_for_transactions(): Check_Assigned_Addresses_For_Transactions_Result {
369
370        $addresses = $this->wallet_service->get_unknown_bitcoin_addresses();
371
372        if ( empty( $addresses ) ) {
373            $this->logger->debug( 'No addresses with "unknown" status to check' );
374
375            return new Check_Assigned_Addresses_For_Transactions_Result(
376                count: 0
377            ); // TODO: return something meaningful.
378        }
379
380        return $this->check_addresses_for_transactions( $addresses );
381    }
382
383    /**
384     * Query the blockchain for transactions on multiple addresses and update their status.
385     *
386     * @used-by Background_Jobs_Actions_Handler::check_new_addresses_for_transactions()
387     *
388     * @param Bitcoin_Address[] $addresses Array of address objects to query for transactions and save results to the database.
389     *
390     * @return Check_Assigned_Addresses_For_Transactions_Result Result containing the count of addresses successfully checked before encountering rate limits or errors.
391     *
392     * @throws Rate_Limit_Exception When the blockchain API rate limit (HTTP 429) is hit, so the job can be rescheduled using the exception's reset time.
393     */
394    protected function check_addresses_for_transactions( array $addresses ): Check_Assigned_Addresses_For_Transactions_Result {
395
396        $result = array();
397
398        try {
399            foreach ( $addresses as $bitcoin_address ) {
400                $update_result = $this->payment_service->update_address_transactions( $bitcoin_address );
401                $this->wallet_service->update_address_transactions_posts( $bitcoin_address, $update_result->all_transactions );
402
403                if ( $bitcoin_address->get_status() === Bitcoin_Address_Status::UNKNOWN ) {
404                    $this->wallet_service->set_payment_address_status(
405                        address: $bitcoin_address,
406                        status: ( 0 === count( $update_result->all_transactions ) ) ? Bitcoin_Address_Status::UNUSED : Bitcoin_Address_Status::USED
407                    );
408                }
409
410                $result[ $bitcoin_address->get_raw_address() ] = $update_result;
411            }
412        } catch ( Rate_Limit_Exception $exception ) {
413            // Reschedule if we hit 429 (there will always be at least one address to check if it 429s.).
414
415            $this->background_jobs_scheduler->schedule_check_newly_generated_bitcoin_addresses_for_transactions(
416                datetime: $exception->get_reset_time()
417            );
418
419            return new Check_Assigned_Addresses_For_Transactions_Result(
420                count: count( $result )
421            );
422        } catch ( Exception $exception ) {
423            $this->logger->error( $exception->getMessage() );
424
425            $this->background_jobs_scheduler->schedule_check_newly_generated_bitcoin_addresses_for_transactions(
426                new DateTimeImmutable()->add( new DateInterval( 'PT15M' ) ),
427            );
428        }
429
430        // TODO: After this is complete, there could be 0 fresh addresses (e.g. if we start at index 0 but 200 addresses
431        // are already used). => We really need to generate new addresses until we have some.
432
433        // TODO: Return something useful.
434        return new Check_Assigned_Addresses_For_Transactions_Result(
435            count: count( $result )
436        );
437    }
438
439
440
441    /**
442     * @see Background_Jobs_Actions_Interface::check_assigned_addresses_for_transactions()
443     * @used-by Background_Jobs_Actions_Handler::check_assigned_addresses_for_transactions()
444     */
445    public function check_assigned_addresses_for_payment(): Check_Assigned_Addresses_For_Transactions_Result {
446
447        foreach ( $this->wallet_service->get_assigned_bitcoin_addresses() as $bitcoin_address ) {
448            $this->check_address_for_payment( $bitcoin_address );
449        }
450        // TODO: Return actual result with count of addresses checked.
451        return new Check_Assigned_Addresses_For_Transactions_Result( count:0 );
452    }
453
454    /**
455     * Check a Bitcoin address for payment and mark as paid if sufficient funds received.
456     *
457     * @param Bitcoin_Address $payment_address The Bitcoin address to check.
458     * @throws BH_WP_Bitcoin_Gateway_Exception If the address being checked has no `target_amount` set.
459     * @throws MoneyMismatchException If the `target_amount` and `total_received` currencies were somehow different.
460     */
461    public function check_address_for_payment( Bitcoin_Address $payment_address ): Check_Address_For_Payment_Result {
462
463        // TODO: Maybe throw if the address has not been assigned.
464        // TODO: Check _when_ the address was assigned and discard any transactions before that time. This should
465        // never actually happen due to other checks that are present, but it's a simple and logical check to not
466        // count payments received before the order was placed.
467
468        $check_address_for_payment_service_result = $this->payment_service->check_address_for_payment( $payment_address );
469
470        // Always "update" transactions here to record the time it was last checked.
471        $this->wallet_service->update_address_transactions_posts( $payment_address, $check_address_for_payment_service_result->all_transactions );
472
473        // If there are new transactions, fire an action to let integrations know.
474        $this->maybe_fire_new_transactions_seen_action( $payment_address, $check_address_for_payment_service_result );
475
476        // `$payment_address` should be NOT refreshed at this point, hopefully none of the methods called before this called `::refresh()`.
477        $this->maybe_mark_address_as_paid( $payment_address, $check_address_for_payment_service_result );
478
479        return new Check_Address_For_Payment_Result(
480            check_address_for_payment_service_result: $check_address_for_payment_service_result,
481            is_paid: $check_address_for_payment_service_result->is_paid(),
482            refreshed_address: $this->wallet_service->refresh_address( $payment_address ),
483        );
484    }
485
486    /**
487     * Fire the `bh_wp_bitcoin_gateway_new_transactions_seen` action, whe appropriate.
488     *
489     * @param Bitcoin_Address                          $payment_address The address that was checked by cron / checked by user interaction.
490     * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The full details of before/after transactions and the confirmed/unconfirmed values based on the address's requirements.
491     */
492    protected function maybe_fire_new_transactions_seen_action(
493        Bitcoin_Address $payment_address,
494        Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result
495    ): void {
496
497        if ( ! $check_address_for_payment_service_result->is_updated() ) {
498            return;
499        }
500
501        $payment_address = $this->wallet_service->refresh_address( $payment_address );
502        $order_post_id   = $payment_address->get_order_id();
503        $integration_id  = $payment_address->get_integration_id();
504
505        /**
506         * When new transactions have been seen for a payment address, fire an action so integrations can act,
507         *
508         * E.g. to log the txid+time+href to blockchain.com/explorer as notes on a WooCommerce order.
509         *
510         * @example {@see Order::new_transactions_seen()}
511         *
512         * @param string|class-string|null $integration_id The bh-wp-bitcoin-gateway integration that "owns" the payment address.
513         * @param ?int $order_post_id The post_id that the integration will know how to manage.
514         * @param Bitcoin_Address $payment_address The payment address that was assigned to that order.
515         * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The prior state + new transaction detail.
516         */
517        do_action( 'bh_wp_bitcoin_gateway_new_transactions_seen', $integration_id, $order_post_id, $payment_address, $check_address_for_payment_service_result );
518    }
519
520    /**
521     * Mark a Bitcoin address as paid and notify integrations.
522     *
523     * TODO: maybe split this into ~"set address status to used" and ~"fire action to alert integrations".
524     *
525     * @used-by self::check_address_for_payment()
526     *
527     * @param Bitcoin_Address                          $payment_address The Bitcoin address to mark as paid.
528     * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The full detail of the before/after transactions for this address.
529     * @throws BH_WP_Bitcoin_Gateway_Exception If there is no target amount set on the payment address.
530     */
531    protected function maybe_mark_address_as_paid(
532        Bitcoin_Address $payment_address,
533        Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result
534    ): Mark_Address_As_Paid_Result {
535
536        if ( ! $check_address_for_payment_service_result->is_paid() ) {
537            return new Mark_Address_As_Paid_Result(
538                $payment_address,
539                $payment_address->get_status(),
540            );
541        }
542
543        $target_amount = $payment_address->get_target_amount();
544        if ( is_null( $target_amount ) ) {
545            throw new BH_WP_Bitcoin_Gateway_Exception( 'No target payment amount on address "' . $payment_address->get_raw_address() . '"' );
546        }
547
548        $status_before = $payment_address->get_status();
549
550        $this->wallet_service->set_payment_address_status(
551            address: $payment_address,
552            status: Bitcoin_Address_Status::USED
553        );
554
555        $this->maybe_fire_mark_address_paid_action( $payment_address, $check_address_for_payment_service_result );
556
557        return new Mark_Address_As_Paid_Result(
558            $payment_address,
559            $status_before,
560        );
561    }
562
563    /**
564     * Fire `bh_wp_bitcoin_gateway_payment_received` action when `$target_amount` is reached.
565     *
566     * @param Bitcoin_Address                          $payment_address_before The payment address (we will refresh this).
567     * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The details of the new transactions.
568     */
569    protected function maybe_fire_mark_address_paid_action(
570        Bitcoin_Address $payment_address_before,
571        Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result
572    ): void {
573
574        // We've probably already checked this before reaching this point.
575        if ( ! $check_address_for_payment_service_result->is_paid() ) {
576            return;
577        }
578
579        $order_post_id   = $payment_address_before->get_order_id();
580        $integration_id  = $payment_address_before->get_integration_id();
581        $payment_address = $this->wallet_service->refresh_address( $payment_address_before );
582
583        /**
584         *
585         *
586         * `bh_wp_bitcoin_gateway_new_transactions_seen` will _always_ be fired before this.
587         *
588         * {@see Check_Address_For_Payment_Service_Result::$queried_address} has the state of the address before.
589         *
590         * @param string|class-string|null $integration_id The bh-wp-bitcoin-gateway integration that "owns" the payment address.
591         * @param ?int $order_post_id The post_id that the integration will know how to manage.
592         * @param Bitcoin_Address $payment_address The payment address that was assigned to that order.
593         * @param Check_Address_For_Payment_Service_Result $check_address_for_payment_service_result The prior state + new transaction detail.
594         */
595        do_action( 'bh_wp_bitcoin_gateway_payment_received', $integration_id, $order_post_id, $payment_address, $check_address_for_payment_service_result );
596    }
597
598    /**
599     * Get saved transactions for a Bitcoin address (`null` if never checked).
600     *
601     * @param Bitcoin_Address $bitcoin_address The Bitcoin address to get transactions for.
602     * @return ?array<int,Bitcoin_Transaction> WP post_id: transaction object.
603     * @throws BH_WP_Bitcoin_Gateway_Exception If one of the post IDs does not match the transaction post type.
604     */
605    public function get_saved_transactions( Bitcoin_Address $bitcoin_address ): ?array {
606
607        $transaction_post_ids = $bitcoin_address->get_tx_ids();
608
609        if ( is_null( $transaction_post_ids ) ) {
610            return null;
611        }
612
613        return $this->payment_service->get_saved_transactions( array_keys( $transaction_post_ids ) );
614    }
615}