Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.60% covered (warning)
72.60%
53 / 73
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bitcoin_Address_Factory
72.60% covered (warning)
72.60%
53 / 73
72.73% covered (warning)
72.73%
8 / 11
39.90
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_by_wp_post_id
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 get_by_wp_post
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 get_derivation_path_sequence_number_from_post
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 get_target_amount_from_post
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_integration_id_from_post_meta
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 get_order_id_from_post
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_tx_ids_from_post
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 get_received_from_post
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_json_mapped_money_from_post
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 log_meta_value_warning
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Mostly takes a WP_Post and returns a Bitcoin_Address
4 *
5 * @package    brianhenryie/bh-wp-bitcoin-gateway
6 */
7
8namespace BrianHenryIE\WP_Bitcoin_Gateway\API\Repositories\Factories;
9
10use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Exceptions\BH_WP_Bitcoin_Gateway_Exception;
11use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address;
12use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address_Status;
13use BrianHenryIE\WP_Bitcoin_Gateway\API\Model\Wallet\Bitcoin_Address_WP_Post_Interface;
14use BrianHenryIE\WP_Bitcoin_Gateway\Brick\Money\Money;
15use BrianHenryIE\WP_Bitcoin_Gateway\JsonMapper\JsonMapperInterface;
16use DateMalformedStringException;
17use DateTimeImmutable;
18use InvalidArgumentException;
19use Psr\Log\LoggerAwareInterface;
20use Psr\Log\LoggerAwareTrait;
21use Psr\Log\LoggerInterface;
22use Throwable;
23use WP_Post;
24
25/**
26 * Some fields are optional (e.g. target amount is only set after an address is assigned) and errors with those
27 * (i.e. parsing meta values to objects) fail soft with warnings logged. Non-optional field throw exceptions on
28 * failures.
29 *
30 * @phpstan-type MoneySerializedArray array{amount:string,currency:string}
31 */
32class Bitcoin_Address_Factory implements LoggerAwareInterface {
33    use LoggerAwareTrait;
34
35    /**
36     * Constructor.
37     *
38     * @param JsonMapperInterface $json_mapper To parse JSON to typed objects.
39     * @param LoggerInterface     $logger PSR logger for failures parsing metadata to values.
40     */
41    public function __construct(
42        protected JsonMapperInterface $json_mapper,
43        LoggerInterface $logger,
44    ) {
45        $this->setLogger( $logger );
46    }
47
48    /**
49     * @param int $post_id The WordPress post id this wallet is stored under.
50     *
51     * @throws InvalidArgumentException When the supplied post_id is not a post of this type.
52     */
53    public function get_by_wp_post_id( int $post_id ): Bitcoin_Address {
54        $post = get_post( $post_id );
55        if ( ! ( $post instanceof WP_Post ) || Bitcoin_Address_WP_Post_Interface::POST_TYPE !== $post->post_type ) {
56            throw new InvalidArgumentException( 'post_id ' . $post_id . ' is not a ' . Bitcoin_Address_WP_Post_Interface::POST_TYPE . ' post object' );
57        }
58
59        return $this->get_by_wp_post( $post );
60    }
61
62    /**
63     * Takes a WP_Post and gets the values (primitives?) to create a Bitcoin_Address.
64     *
65     * The first call to {@see get_post_meta()} caches all meta for the object, {@see get_metadata_raw()}.
66     *
67     * @param WP_Post $post The backing WP_Post for this Bitcoin_Address.
68     * @throws DateMalformedStringException If somehow {@see WP_Post::$post_modified_gmt} is not in the expected format.
69     */
70    public function get_by_wp_post( WP_Post $post ): Bitcoin_Address {
71
72        return new Bitcoin_Address(
73            post_id: $post->ID,
74            wallet_parent_post_id: $post->post_parent,
75            raw_address: $post->post_title,
76            derivation_path_sequence_number: $this->get_derivation_path_sequence_number_from_post( $post ),
77            created_time: new DateTimeImmutable( $post->post_date_gmt ),
78            modified_time: new DateTimeImmutable( $post->post_modified_gmt ),
79            status: Bitcoin_Address_Status::from( $post->post_status ),
80            target_amount: $this->get_target_amount_from_post( $post ),
81            integration_id: $this->get_integration_id_from_post_meta( $post ),
82            order_id: $this->get_order_id_from_post( $post ),
83            tx_ids: $this->get_tx_ids_from_post( $post ),
84            received: $this->get_received_from_post( $post ),
85        );
86    }
87
88    /**
89     * @param WP_Post $post The backing WP_Post for this Bitcoin_Address.
90     */
91    protected function get_derivation_path_sequence_number_from_post( WP_Post $post ): int {
92        /** @var array|bool|float|int|resource|string|null|mixed $meta_value */
93        $meta_value = get_post_meta( $post->ID, Bitcoin_Address_WP_Post_Interface::DERIVATION_PATH_SEQUENCE_NUMBER_META_KEY, true );
94        return is_numeric( $meta_value )
95            ? intval( $meta_value )
96            : ( function () use ( $meta_value ) {
97                throw new BH_WP_Bitcoin_Gateway_Exception( 'get_derivation_path_sequence_number_from_post failed for ' . wp_json_encode( $meta_value ) );
98            } )();
99    }
100
101    /**
102     * @param WP_Post $post The backing WP_Post for this Bitcoin_Address.
103     */
104    protected function get_target_amount_from_post( WP_Post $post ): ?Money {
105        return $this->get_json_mapped_money_from_post(
106            post_id: $post->ID,
107            meta_key: Bitcoin_Address_WP_Post_Interface::TARGET_AMOUNT_META_KEY
108        );
109    }
110
111    /**
112     * @param WP_Post $post The backing WP_Post for this Bitcoin_Address.
113     */
114    protected function get_integration_id_from_post_meta( WP_Post $post ): ?string {
115        /** @var array|bool|float|int|resource|string|null|mixed $integration_id_meta */
116        $integration_id_meta = get_post_meta( $post->ID, Bitcoin_Address_WP_Post_Interface::INTEGRATION_ID_META_KEY, true );
117        return is_string( $integration_id_meta ) && ! empty( $integration_id_meta ) ? $integration_id_meta : null;
118    }
119
120    /**
121     * @param WP_Post $post The backing WP_Post for this Bitcoin_Address.
122     */
123    protected function get_order_id_from_post( WP_Post $post ): ?int {
124        /** @var array|bool|float|int|resource|string|null|mixed $order_id_meta */
125        $order_id_meta = get_post_meta( $post->ID, Bitcoin_Address_WP_Post_Interface::ORDER_ID_META_KEY, true );
126        return is_numeric( $order_id_meta ) ? intval( $order_id_meta ) : null;
127    }
128
129    /**
130     * @param WP_Post $post The backing WP_Post for this Bitcoin_Address.
131     * @return array<int,string>|null
132     */
133    protected function get_tx_ids_from_post( WP_Post $post ): ?array {
134        /** @var string|null|mixed $tx_ids_meta */
135        $tx_ids_meta = get_post_meta( $post->ID, Bitcoin_Address_WP_Post_Interface::TRANSACTIONS_META_KEY, true );
136        if ( empty( $tx_ids_meta ) ) {
137            return null;
138        }
139        if ( ! is_string( $tx_ids_meta ) ) {
140            $this->log_meta_value_warning( $post->ID, Bitcoin_Address_WP_Post_Interface::TRANSACTIONS_META_KEY, 'array of txids', $tx_ids_meta );
141            return null;
142        }
143        /** @var array<int,string>|null|mixed $tx_ids_meta_array */
144        $tx_ids_meta_array = json_decode( $tx_ids_meta, true );
145        if ( ! is_array( $tx_ids_meta_array ) ) {
146            $this->log_meta_value_warning( $post->ID, Bitcoin_Address_WP_Post_Interface::TRANSACTIONS_META_KEY, 'array of txids', $tx_ids_meta, json_last_error_msg() );
147            return null;
148        }
149        foreach ( $tx_ids_meta_array as $post_id => $tx_id ) {
150            if ( ! is_int( $post_id ) || ! is_string( $tx_id ) ) {
151                $this->log_meta_value_warning( $post->ID, Bitcoin_Address_WP_Post_Interface::TRANSACTIONS_META_KEY, 'array of txids', $tx_ids_meta );
152                return null;
153            }
154        }
155        /** @var array<int,string> $tx_ids_meta_array */
156        return $tx_ids_meta_array;
157    }
158
159    /**
160     * @param WP_Post $post The backing WP_Post for this Bitcoin_Address.
161     */
162    protected function get_received_from_post( WP_Post $post ): ?Money {
163        return $this->get_json_mapped_money_from_post(
164            post_id: $post->ID,
165            meta_key: Bitcoin_Address_WP_Post_Interface::CONFIRMED_AMOUNT_RECEIVED_META_KEY
166        );
167    }
168
169    /**
170     * Use JSON Mapper to parse the meta value to a Money object, if it cannot be parsed, record a warning and return null.
171     *
172     * @param int    $post_id The ID of the WP Post that contained the invalid value.
173     * @param string $meta_key The name of the value we were looking for.
174     */
175    protected function get_json_mapped_money_from_post( int $post_id, string $meta_key ): ?Money {
176        /** @var mixed|MoneySerializedArray $meta_value */
177        $meta_value = get_post_meta( $post_id, $meta_key, true );
178        if ( empty( $meta_value ) ) {
179            // Empty meta is valid for unassigned addresses and those without transactions.
180            return null;
181        }
182        if ( ! is_string( $meta_value ) ) {
183            $this->log_meta_value_warning( $post_id, $meta_key, 'Money', $meta_value );
184            return null;
185        }
186        try {
187            return $this->json_mapper->mapToClassFromString( $meta_value, Money::class );
188        } catch ( Throwable $exception ) {
189            $this->log_meta_value_warning( $post_id, $meta_key, 'Money', $meta_value, '', $exception );
190            return null;
191        }
192    }
193
194    /**
195     * Log a useful message when the meta value could not be parsed to a Money object.
196     *
197     * @param int        $post_id ID for the WP_Post holding the data.
198     * @param string     $meta_key The key.
199     * @param string     $type Human-readable type for the message.
200     * @param mixed      $meta_value The saved value, not empty.
201     * @param string     $extra An extra note to include in the message, default empty.
202     * @param ?Throwable $throwable Potentially thrown exception.
203     * @see LoggerInterface::warning()
204     */
205    protected function log_meta_value_warning(
206        int $post_id,
207        string $meta_key,
208        string $type,
209        mixed $meta_value,
210        string $extra = '',
211        ?Throwable $throwable = null
212    ): void {
213        $this->logger->warning(
214            'Failed to parse payment address meta {meta_key} as {type} for post id {post_id} {extra}, value: {meta_value}',
215            array(
216                'exception'  => $throwable,
217                'post_id'    => $post_id,
218                'meta_key'   => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
219                'type'       => $type,
220                'extra'      => $extra,
221                'meta_value' => $meta_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
222            )
223        );
224    }
225}