Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.00% covered (warning)
86.00%
43 / 50
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Exchange_Rate_Service
86.00% covered (warning)
86.00%
43 / 50
75.00% covered (warning)
75.00%
6 / 8
14.54
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
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
9.06
 update_exchange_rate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fetch_exchange_rate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 convert_fiat_to_btc
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_transient_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_cached_exchange_rate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 get_cached_exchange_rate
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
1<?php
2/**
3 * Fetch & store the current BTC exchange rate; perform currency conversions.
4 *
5 * @package brianhenryie/bh-wp-bitcoin-gateway
6 */
7
8namespace BrianHenryIE\WP_Bitcoin_Gateway\API\Services;
9
10use BrianHenryIE\WP_Bitcoin_Gateway\API\Clients\Exchange_Rate_API_Interface;
11use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception;
12use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\Rate_Limit_Exception;
13use BrianHenryIE\WP_Bitcoin_Gateway\API\Services\Results\Exchange_Rate_Service_Result;
14use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Math\BigDecimal;
15use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Math\Exception\MathException;
16use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Math\RoundingMode;
17use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Currency;
18use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Exception\UnknownCurrencyException;
19use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money;
20use BrianHenryIE\WP_Bitcoin_Gateway\JsonMapper\JsonMapperInterface;
21use DateTimeImmutable;
22use JsonException;
23use Psr\Log\LoggerAwareInterface;
24use Psr\Log\LoggerAwareTrait;
25use Psr\Log\LoggerInterface;
26
27/**
28 * Uses a `Exchange_Rate_API_Interface` implementation and saves the result, JSON encoded, in a transient.
29 */
30class Exchange_Rate_Service implements LoggerAwareInterface {
31    use LoggerAwareTrait;
32
33    /**
34     * 1 BTC = 100_000_000 (10^8) Sats (Satoshis).
35     */
36    public const int SATOSHI_RATE = 100_000_000;
37
38    protected const string TRANSIENT_BASE = 'bh_wp_bitcoin_gateway_exchange_rate_';
39
40    /**
41     * Constructor
42     *
43     * @param Exchange_Rate_API_Interface $exchange_rate_api External API to fetch exchange rate data from.
44     * @param JsonMapperInterface         $json_mapper To parse JSON to typed objects.
45     * @param LoggerInterface             $logger PSR logger for debug and errors.
46     */
47    public function __construct(
48        protected Exchange_Rate_API_Interface $exchange_rate_api,
49        protected JsonMapperInterface $json_mapper,
50        LoggerInterface $logger,
51    ) {
52        $this->setLogger( $logger );
53    }
54
55    /**
56     * Get the value of 1 BTC in the requested currency, hopefully from cache.
57     *
58     * Return the cached exchange rate, or fetch it.
59     * Caches for one hour.
60     *
61     * TODO: Add rate limiting.
62     *
63     * @param Currency $currency The fiat currency to get the BTC exchange rate for (e.g., USD, EUR, GBP).
64     *
65     * @throws BH_WP_Bitcoin_Gateway_Exception When the exchange rate API returns invalid data or the currency is not supported.
66     */
67    public function get_exchange_rate( Currency $currency ): ?Money {
68
69        $exchange_rate_stored_transient = $this->get_cached_exchange_rate( $currency );
70
71        if ( ! is_null( $exchange_rate_stored_transient ) ) {
72            return $exchange_rate_stored_transient->rate;
73        }
74
75        try {
76            $exchange_rate_service_result = $this->fetch_exchange_rate( $currency );
77        } catch ( Rate_Limit_Exception ) {
78            // TODO: set up background job.
79            return null;
80        } catch ( UnknownCurrencyException ) {
81            // Ignore this error.
82            // It could only happen if the currency of the Money object passed to the function was not
83            // recognised by brick/money which doesn't make sense. I.e. the exception would have happened
84            // before this function was called.
85            return null;
86        } catch ( JsonException ) {
87            // TODO: decide if this should be logged inside the API class.
88            return null;
89        }
90
91        return $exchange_rate_service_result->rate;
92    }
93
94    /**
95     * Synchronously check the exchange rate; include the previous cached value in the result.
96     *
97     * @param Currency $currency The currency to fetch the exchange rate for.
98     *
99     * @throws BH_WP_Bitcoin_Gateway_Exception If the request fails.
100     * @throws JsonException If the API response body is not JSON, as expected.
101     * @throws UnknownCurrencyException Almost impossible… it would mean the brick/money library that created the Currency object does not recognise it.
102     */
103    public function update_exchange_rate( Currency $currency ): Exchange_Rate_Service_Result {
104
105        $previous                                 = $this->get_cached_exchange_rate( $currency );
106        $updated                                  = (array) $this->fetch_exchange_rate( $currency );
107        $updated['previous_cached_exchange_rate'] = $previous;
108
109        // Allow array destructuring in constructor calls.
110        return new Exchange_Rate_Service_Result( ...$updated ); // @phpstan-ignore argument.type
111    }
112
113    /**
114     * Synchronously check (then cache) the exchange rate.
115     *
116     * @param Currency $currency The currency to fetch the exchange rate for.
117     *
118     * @throws BH_WP_Bitcoin_Gateway_Exception If the request fails.
119     * @throws JsonException If the API response body is not JSON, as expected.
120     * @throws UnknownCurrencyException Almost impossible… it would mean the brick/money library that created the Currency object does not recognise it.
121     */
122    protected function fetch_exchange_rate( Currency $currency ): Exchange_Rate_Service_Result {
123        $exchange_rate_money = $this->exchange_rate_api->get_exchange_rate( $currency );
124
125        return $this->set_cached_exchange_rate(
126            rate: $exchange_rate_money,
127            api_classname: $this->exchange_rate_api::class
128        );
129    }
130
131    /**
132     * Get the BTC value of another currency amount.
133     *
134     * Limited currency support: 'USD'|'EUR'|'GBP', maybe others.
135     *
136     * @param Money $fiat_amount The order total amount in fiat currency from the WooCommerce order (stored as a float string in order meta).
137     *
138     * @throws BH_WP_Bitcoin_Gateway_Exception When no exchange rate is available for the given currency.
139     * @throws UnknownCurrencyException If somehow the currency requested in the Money `$fiat_amount` parameter doesn't exist.
140     * @throws MathException If we attempt to divide by zero.
141     */
142    public function convert_fiat_to_btc( Money $fiat_amount ): Money {
143
144        $exchange_rate = $this->get_exchange_rate( $fiat_amount->getCurrency() );
145
146        if ( is_null( $exchange_rate ) ) {
147            throw new BH_WP_Bitcoin_Gateway_Exception( 'No exchange rate available' );
148        }
149
150        // 1 BTC = xx USD.
151        $exchange_rate = BigDecimal::of( '1' )->dividedBy( $exchange_rate->getAmount(), 24, RoundingMode::HALF_EVEN );
152
153        return $fiat_amount->convertedTo( Currency::of( 'BTC' ), $exchange_rate, null, RoundingMode::HALF_EVEN );
154    }
155
156    /**
157     * E.g. `bh_wp_bitcoin_gateway_exchange_rate_USD`.
158     *
159     * @param Currency $currency The currency we have just fetched the exchange rate for.
160     */
161    protected function get_transient_name( Currency $currency ): string {
162        return self::TRANSIENT_BASE . $currency->getCurrencyCode();
163    }
164
165    /**
166     * Save using transients
167     *
168     * Maybe later directly use {@see wp_using_ext_object_cache}, {@see wp_cache_set()}.
169     *
170     * @param Money  $rate Money object where the value in that currency equals one BTC.
171     * @param string $api_classname The API that was used to fetch the exchange rate.
172     */
173    protected function set_cached_exchange_rate(
174        Money $rate,
175        string $api_classname,
176    ): Exchange_Rate_Service_Result {
177
178        $exchange_rate = new Exchange_Rate_Service_Result(
179            rate: $rate,
180            api_classname: $api_classname,
181            date_saved: new DateTimeImmutable(),
182        );
183
184        // This returns a bool. If setting transients is failing, we should broadly rate limit trying anything.
185        set_transient(
186            transient: $this->get_transient_name(
187                currency: $rate->getCurrency()
188            ),
189            value: wp_json_encode( $exchange_rate ),
190            expiration: HOUR_IN_SECONDS
191        );
192
193        return $exchange_rate;
194    }
195
196    /**
197     * Get the JSON encoded transient, parse it to an object.
198     *
199     * @param Currency $currency The currency to fetch (mostly for the transient name).
200     */
201    protected function get_cached_exchange_rate( Currency $currency ): ?Exchange_Rate_Service_Result {
202
203        /** @var false|string $exchange_rate_stored_transient_json_string */
204        $exchange_rate_stored_transient_json_string = get_transient(
205            transient: $this->get_transient_name( $currency )
206        );
207
208        if ( ! is_string( $exchange_rate_stored_transient_json_string ) ) {
209            return null;
210        }
211
212        /** @var Exchange_Rate_Service_Result $exchange_rate */
213        $exchange_rate = $this->json_mapper->mapToClassFromString(
214            $exchange_rate_stored_transient_json_string,
215            Exchange_Rate_Service_Result::class
216        );
217
218        return $exchange_rate;
219    }
220}