Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.35% covered (success)
97.35%
110 / 113
81.25% covered (warning)
81.25%
13 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bitcoin_Address_Repository
97.35% covered (success)
97.35%
110 / 113
81.25% covered (warning)
81.25%
13 / 16
20
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_post_id_for_address
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 get_by_post_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_by_wp_post
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 refresh
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_assigned_bitcoin_addresses
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 get_unused_bitcoin_addresses
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 has_assigned_bitcoin_addresses
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 get_unknown_bitcoin_addresses
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_addresses
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_addresses_query
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 save_new_address
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 set_status
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 assign_to_order
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 set_wallet_id
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 set_transactions_post_ids_to_address
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Save new Bitcoin addresses in WordPress, and fetch them via xpub or post id.
4 *
5 * @package    brianhenryie/bh-wp-bitcoin-gateway
6 */
7
8namespace BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories;
9
10use BrianHenryIE\WP_Bitcoin_Gateway\Admin\Addresses_List_Table;
11use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address;
12use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Factories\Bitcoin_Address_Factory;
13use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Queries\Bitcoin_Address_Query;
14use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address_Status;
15use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address_WP_Post_Interface;
16use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Wallet;
17use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception;
18use BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Queries\WP_Posts_Query_Order;
19use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money;
20use DateMalformedStringException;
21use WP_Post;
22use wpdb;
23
24/**
25 * Interface for creating/getting Bitcoin_Address objects stored in wp_posts table.
26 *
27 * @see Bitcoin_Address_WP_Post_Interface
28 *
29 * @phpstan-type WpUpdatePostArray array{ID?: int, post_author?: int, post_date?: string, post_date_gmt?: string, post_content?: string, post_content_filtered?: string, post_title?: string, post_excerpt?: string}
30 */
31class Bitcoin_Address_Repository extends WP_Post_Repository_Abstract {
32
33    /**
34     * Constructor.
35     *
36     * @param Bitcoin_Address_Factory $bitcoin_address_factory Factory for creating Bitcoin payment address objects.
37     */
38    public function __construct(
39        protected Bitcoin_Address_Factory $bitcoin_address_factory,
40    ) {
41    }
42
43    /**
44     * Given a bitcoin public key, get the WordPress post_id it is saved under.
45     *
46     * @param string $address Xpub|ypub|zpub.
47     *
48     * @return int|null The post id if it exists, null if it is not found.
49     */
50    public function get_post_id_for_address( string $address ): ?int {
51
52        $cached = wp_cache_get( $address, Bitcoin_Address_WP_Post_Interface::POST_TYPE );
53        if ( is_numeric( $cached ) ) {
54            return intval( $cached );
55        }
56
57        /** @var wpdb $wpdb */
58        global $wpdb;
59        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
60        // @phpstan-ignore-next-line
61        $post_id = $wpdb->get_var(
62            $wpdb->prepare(
63                'SELECT ID FROM %i WHERE post_name=%s AND post_type=%s',
64                $wpdb->posts,
65                sanitize_title( $address ),
66                Bitcoin_Address_WP_Post_Interface::POST_TYPE
67            )
68        );
69
70        if ( is_numeric( $post_id ) ) {
71            $post_id = intval( $post_id );
72            wp_cache_set( $address, $post_id, Bitcoin_Address_WP_Post_Interface::POST_TYPE );
73            return $post_id;
74        }
75
76        return null;
77    }
78
79    /**
80     * Given the id of the wp_posts row storing the bitcoin address, return the typed Bitcoin_Address object.
81     *
82     * @param int $post_id WordPress wp_posts ID.
83     *
84     * @return Bitcoin_Address
85     * @throws BH_WP_Bitcoin_Gateway_Exception When the post_type of the post returned for the given post_id is not a Bitcoin_Address.
86     */
87    public function get_by_post_id( int $post_id ): Bitcoin_Address {
88        return $this->bitcoin_address_factory->get_by_wp_post_id( $post_id );
89    }
90
91    /**
92     * @used-by Addresses_List_Table::get_bitcoin_address_object()
93     *
94     * @param WP_Post $post A WP_Post storing address information.
95     * @throws DateMalformedStringException If the database date could not be parsed – very unlikely.
96     */
97    public function get_by_wp_post( WP_Post $post ): Bitcoin_Address {
98        return $this->bitcoin_address_factory->get_by_wp_post( $post );
99    }
100
101    /**
102     * Refresh a saved Bitcoin payment address object from the database.
103     *
104     * NB: This does not perform any API calls.
105     *
106     * @param Bitcoin_Address $address The address to refresh.
107     * @return Bitcoin_Address The refreshed Bitcoin address.
108     */
109    public function refresh( Bitcoin_Address $address ): Bitcoin_Address {
110        return $this->bitcoin_address_factory->get_by_wp_post_id( $address->get_post_id() );
111    }
112
113    /**
114     * @return Bitcoin_Address[]
115     */
116    public function get_assigned_bitcoin_addresses(): array {
117        return $this->get_addresses_query(
118            new Bitcoin_Address_Query(
119                status: Bitcoin_Address_Status::ASSIGNED,
120            ),
121            new WP_Posts_Query_Order(
122                count: 200,
123            )
124        );
125    }
126
127    /**
128     * Gets previously saved addresses which have at least once been checked and see to be unused.
129     *
130     * It may be the case that they have been used in the meantime.
131     *
132     * @param ?Bitcoin_Wallet       $wallet Optional wallet to filter addresses by.
133     * @param ?WP_Posts_Query_Order $query_order Object to sort and filter the results.
134     * @return Bitcoin_Address[]
135     */
136    public function get_unused_bitcoin_addresses(
137        ?Bitcoin_Wallet $wallet = null,
138        ?WP_Posts_Query_Order $query_order = null
139    ): array {
140        return $this->get_addresses_query(
141            new Bitcoin_Address_Query(
142                wallet_wp_post_parent_id: $wallet?->get_post_id(),
143                status: Bitcoin_Address_Status::UNUSED,
144            ),
145            $query_order ?? new WP_Posts_Query_Order(
146                count: 200,
147                order_by: 'post_modified',
148                order_direction: 'ASC',
149            )
150        );
151    }
152
153    /**
154     * Check do we have at least 1 assigned address, i.e. an address waiting for transactions.
155     *
156     * Across all wallets.
157     */
158    public function has_assigned_bitcoin_addresses(): bool {
159        return ! empty(
160            $this->get_addresses_query(
161                new Bitcoin_Address_Query(
162                    status: Bitcoin_Address_Status::ASSIGNED,
163                ),
164                new WP_Posts_Query_Order(
165                    count: 1,
166                )
167            )
168        );
169    }
170
171    /**
172     * @return Bitcoin_Address[]
173     */
174    public function get_unknown_bitcoin_addresses(): array {
175        return $this->get_addresses_query(
176            new Bitcoin_Address_Query(
177                status: Bitcoin_Address_Status::UNKNOWN,
178            )
179        );
180    }
181
182    /**
183     * Get all saved Bitcoin payment address.
184     *
185     * @param ?Bitcoin_Wallet         $wallet Optional wallet to filter by.
186     * @param ?Bitcoin_Address_Status $status Optional status to filter by.
187     *
188     * @return Bitcoin_Address[]
189     */
190    public function get_addresses(
191        ?Bitcoin_Wallet $wallet = null,
192        ?Bitcoin_Address_Status $status = null
193    ): array {
194
195        return $this->get_addresses_query(
196            new Bitcoin_Address_Query(
197                wallet_wp_post_parent_id: $wallet?->get_post_id(),
198                status: $status ?? Bitcoin_Address_Status::ALL,
199            )
200        );
201    }
202
203    /**
204     * Get addresses matching a query.
205     *
206     * @param Bitcoin_Address_Query $query The query filter to apply.
207     * @param ?WP_Posts_Query_Order $order Common WP_Posts number/order by/direction.
208     * @return Bitcoin_Address[]
209     */
210    protected function get_addresses_query(
211        Bitcoin_Address_Query $query,
212        ?WP_Posts_Query_Order $order = null,
213    ): array {
214
215        $effective_order = $order ?? new WP_Posts_Query_Order(
216            order_by: 'ID',
217            order_direction: 'ASC',
218        );
219
220        /** @var WP_Post[] $posts */
221        $posts = get_posts(
222            $query->to_query_array() + $effective_order->to_query_array()
223        );
224
225        return array_map(
226            $this->bitcoin_address_factory->get_by_wp_post( ... ),
227            $posts
228        );
229    }
230
231    /**
232     * Wrapper on wp_insert_post(), sets the address as the post_title and post_name.
233     *
234     * @param Bitcoin_Wallet $wallet The wallet this address belongs to.
235     * @param int            $derivation_path_sequence_index The derivation path index for this address.
236     * @param string         $address The Bitcoin payment address string.
237     * @return Bitcoin_Address The saved Bitcoin address.
238     * @throws BH_WP_Bitcoin_Gateway_Exception When WordPress fails to create the wp_post.
239     */
240    public function save_new_address(
241        Bitcoin_Wallet $wallet,
242        int $derivation_path_sequence_index,
243        string $address,
244    ): Bitcoin_Address {
245
246        // Unlikely, but was an issue for Wallets.
247        $existing_post_id = $this->get_post_id_for_address( $address );
248        if ( $existing_post_id ) {
249            throw new BH_WP_Bitcoin_Gateway_Exception( 'Attempted to save a payment address that already exists: ' . $address );
250        }
251
252        $query = new Bitcoin_Address_Query(
253            wallet_wp_post_parent_id: $wallet->get_post_id(),
254            status: Bitcoin_Address_Status::UNKNOWN,
255            xpub: $address,
256            derivation_path_sequence_index: $derivation_path_sequence_index,
257        );
258
259        /** @var WpUpdatePostArray $args */
260        $args = $query->to_query_array();
261
262        $post_id = wp_insert_post( $args, true );
263
264        if ( is_wp_error( $post_id ) ) {
265            // TODO Log.
266            throw new BH_WP_Bitcoin_Gateway_Exception( 'WordPress failed to create a post for the wallet.' );
267        }
268
269        return $this->bitcoin_address_factory->get_by_wp_post_id( $post_id );
270    }
271
272    /**
273     * Set the current status of the address.
274     *
275     * Valid statuses: unknown|unused|assigned|used + ~WordPress built-in.
276     *
277     * TODO: Throw an exception if an invalid status is set.
278     *
279     * @param Bitcoin_Address        $address The address to update.
280     * @param Bitcoin_Address_Status $status Status to assign.
281     */
282    public function set_status(
283        Bitcoin_Address $address,
284        Bitcoin_Address_Status $status
285    ): void {
286
287        $this->update(
288            model: $address,
289            query: new Bitcoin_Address_Query(
290                status: $status,
291            )
292        );
293    }
294
295    /**
296     * Associate the Bitcoin Address with an order's post_id, set the expected amount to be paid, change the status
297     * to "assigned".
298     *
299     * @see Bitcoin_Address_Status::ASSIGNED
300     *
301     * @param Bitcoin_Address     $address The Bitcoin payment address to link.
302     * @param string|class-string $integration_id The plugin that is using this address.
303     * @param int                 $order_id The post_id (e.g. WooCommerce order id) that transactions to this address represent payment for.
304     * @param Money               $btc_total The target amount to be paid, after which the order should be updated.
305     */
306    public function assign_to_order(
307        Bitcoin_Address $address,
308        string $integration_id,
309        int $order_id,
310        Money $btc_total,
311    ): void {
312        $this->update(
313            model: $address,
314            query: new Bitcoin_Address_Query(
315                status: Bitcoin_Address_Status::ASSIGNED,
316                integration_id: wp_slash( $integration_id ),
317                associated_order_id: $order_id,
318                target_amount: $btc_total,
319            )
320        );
321    }
322
323    /**
324     * An address wp_post's parent post_id for its wallet may need to be set if the original wallet wp_post was
325     * deleted but the address's wp_post remained orphaned.
326     *
327     * @param Bitcoin_Address $address Address object to update.
328     * @param int             $wallet_post_id The new/correct wallet post_id to set.
329     */
330    public function set_wallet_id( Bitcoin_Address $address, int $wallet_post_id ): void {
331        $this->update(
332            model: $address,
333            query: new Bitcoin_Address_Query(
334                wallet_wp_post_parent_id: $wallet_post_id,
335            )
336        );
337    }
338
339    /**
340     * Set the post meta on an address to link to its transactions.
341     *
342     * Conversely, elsewhere, the address post_id will be linked on the transaction.
343     *
344     * @param Bitcoin_Address    $address The Bitcoin address set the transactions list for.
345     * @param array<int, string> $updated_transactions_post_ids Key/value: <post_id, transaction_id>.
346     */
347    public function set_transactions_post_ids_to_address(
348        Bitcoin_Address $address,
349        array $updated_transactions_post_ids,
350    ): void {
351
352        $this->update(
353            model: $address,
354            query: new Bitcoin_Address_Query(
355                transactions_post_ids: $updated_transactions_post_ids,
356            )
357        );
358    }
359}