Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.43% covered (warning)
71.43%
50 / 70
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tax_Rate_Breakdown_Controller
71.01% covered (warning)
71.01%
49 / 69
64.29% covered (warning)
64.29%
9 / 14
38.03
0.00% covered (danger)
0.00%
0 / 1
 get_report_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_report_label
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_data_endpoint
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_column_headers
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 format_row_for_csv
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 get_tax_rate_percent
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
7.46
 format_percentage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_default_values
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 get_fields
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 format_row_with_comparison
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_matching_field
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_identifying_fields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_empty_row_check_field
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 apply_empty_row_label
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * REST API Reports Tax Rate Breakdown controller class.
4 *
5 * @package Automattic\Jetpack\PremiumAnalytics\Reports\Export\Exports
6 */
7
8declare( strict_types=1 );
9
10namespace Automattic\Jetpack\PremiumAnalytics\Reports\Export\Exports;
11
12defined( 'ABSPATH' ) || exit;
13
14use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Abstract_Csv_Report_Controller;
15use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Report_Data_Fetcher;
16
17/**
18 * Tax Rate Breakdown CSV Export Controller.
19 *
20 * Handles CSV exports for the Tax Rate Breakdown report, showing individual
21 * tax rates with their totals over a period.
22 * Note: This is a ranked list report, not a time-series report.
23 * Comparison mode is supported using ID-based merging (matching by tax_rate_id).
24 *
25 * @since $$next-version$$
26 */
27class Tax_Rate_Breakdown_Controller extends Abstract_Csv_Report_Controller {
28
29    /**
30     * Cache for tax rate percentages by tax_rate_id.
31     *
32     * @var array<int, float|null>
33     */
34    private $tax_rates_cache = array();
35
36    /**
37     * Get the report key for this controller.
38     *
39     * @return string The report key.
40     */
41    public function get_report_key(): string {
42        return 'taxratebreakdown';
43    }
44
45    /**
46     * Get the report label for this controller.
47     *
48     * @return string The report label.
49     */
50    public function get_report_label(): string {
51        return __( 'Tax Rate Breakdown', 'jetpack-premium-analytics' );
52    }
53
54    /**
55     * Get the data endpoint for this controller.
56     *
57     * @return string The data endpoint.
58     */
59    public function get_data_endpoint(): string {
60        return 'reports/taxes/breakdown';
61    }
62
63    /**
64     * Get the column headers for this controller.
65     *
66     * @param string|null $interval Optional time interval for dynamic headers.
67     * @return array The column headers.
68     */
69    public function get_column_headers( ?string $interval = null ): array { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Signature required by the report controller interface.
70        return array(
71            'tax_code'     => __( 'Tax code', 'jetpack-premium-analytics' ),
72            'rate'         => __( 'Rate', 'jetpack-premium-analytics' ),
73            'total_tax'    => __( 'Total tax', 'jetpack-premium-analytics' ),
74            'order_tax'    => __( 'Order tax', 'jetpack-premium-analytics' ),
75            'shipping_tax' => __( 'Shipping tax', 'jetpack-premium-analytics' ),
76            'orders'       => __( 'Orders', 'jetpack-premium-analytics' ),
77        );
78    }
79
80    /**
81     * Format a row for CSV export.
82     *
83     * Empty rows (empty tax code/name) are handled by the parent via get_empty_row_check_field()
84     * and get_empty_row_label(); do not skip them here.
85     *
86     * @param array       $item     The row data.
87     * @param string|null $interval Optional time interval for formatting.
88     * @return array The formatted row.
89     */
90    public function format_row_for_csv( array $item, ?string $interval = null ): array { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Signature required by the report controller interface.
91        $defaults = $this->get_default_values();
92
93        // Get tax_rate_id for lookups.
94        $tax_rate_id = (int) ( $item['tax_rate_id'] ?? $defaults['tax_rate_id'] );
95
96        // Get tax rate, falling back to WooCommerce lookup if missing from API response.
97        // Use array_key_exists to distinguish between missing key and explicit 0 value.
98        if ( array_key_exists( 'tax_rate', $item ) ) {
99            $tax_rate = $item['tax_rate'];
100        } elseif ( ! empty( $tax_rate_id ) ) {
101            $tax_rate = $this->get_tax_rate_percent( $tax_rate_id );
102        } else {
103            $tax_rate = $defaults['tax_rate'];
104        }
105
106        return array(
107            'tax_code'     => $item['tax_rate_code'] ?? $defaults['tax_rate_code'],
108            'rate'         => $this->format_percentage( $tax_rate ),
109            'total_tax'    => self::format_amount( $item['total_tax'] ?? $defaults['total_tax'] ),
110            'order_tax'    => self::format_amount( $item['order_tax'] ?? $defaults['order_tax'] ),
111            'shipping_tax' => self::format_amount( $item['shipping_tax'] ?? $defaults['shipping_tax'] ),
112            'orders'       => $item['orders_count'] ?? $defaults['orders_count'],
113        );
114    }
115
116    /**
117     * Get tax rate percentage from WooCommerce, with caching.
118     *
119     * Retrieves the tax rate percentage for a given tax_rate_id using WC_Tax::get_rate_percent_value().
120     * Results are cached in memory for the duration of the export to avoid repeated database queries.
121     * Returns null if WooCommerce is unavailable or the tax rate ID doesn't exist in the database.
122     *
123     * @param int $tax_rate_id The tax rate ID.
124     * @return float|null The tax rate percentage, or null if not found.
125     */
126    private function get_tax_rate_percent( int $tax_rate_id ): ?float {
127        if ( ! array_key_exists( $tax_rate_id, $this->tax_rates_cache ) ) {
128            if ( ! class_exists( \WC_Tax::class ) ) {
129                $this->tax_rates_cache[ $tax_rate_id ] = null;
130                return $this->tax_rates_cache[ $tax_rate_id ];
131            }
132
133            $tax_rate_code = \WC_Tax::get_rate_code( $tax_rate_id );
134            if ( empty( $tax_rate_code ) ) {
135                $this->tax_rates_cache[ $tax_rate_id ] = null;
136                return $this->tax_rates_cache[ $tax_rate_id ];
137            }
138
139            $this->tax_rates_cache[ $tax_rate_id ] = \WC_Tax::get_rate_percent_value( $tax_rate_id );
140        }
141        return $this->tax_rates_cache[ $tax_rate_id ];
142    }
143
144    /**
145     * Format a percentage value for CSV display.
146     *
147     * Formats as "10.00%" with two decimal places.
148     * Handles negative values by taking absolute value (tax rates should always be positive).
149     * Returns "N/A" when the value is null (rate unavailable from both API and WooCommerce).
150     *
151     * @param float|int|null $value The percentage value to format.
152     * @return string The formatted percentage or "N/A".
153     */
154    private function format_percentage( $value ): string {
155        if ( null === $value ) {
156            return 'N/A';
157        }
158        return number_format( abs( (float) $value ), 2, '.', '' ) . '%';
159    }
160
161    /**
162     * Get default values for missing data fields.
163     *
164     * @return array Array of field_name => default_value pairs.
165     */
166    public function get_default_values(): array {
167        return array(
168            'tax_rate_id'   => 0,
169            'tax_rate_code' => '',
170            'tax_rate'      => null,
171            'total_tax'     => 0,
172            'order_tax'     => 0,
173            'shipping_tax'  => 0,
174            'orders_count'  => 0,
175        );
176    }
177
178    /**
179     * Get the list of API fields needed for this report.
180     *
181     * @return array
182     */
183    public function get_fields(): array {
184        return array(
185            'tax_rate_id',
186            'tax_rate_code',
187            'total_tax',
188            'order_tax',
189            'shipping_tax',
190            'orders_count',
191        );
192    }
193
194    /**
195     * Format a row with comparison, ensuring the comparison tax code matches the original.
196     *
197     * For the same tax rate (same row), the tax code label is identical in both periods.
198     * After the parent adds comparison columns, we set comparison_tax_code to the same
199     * value as tax_code so the label column is consistent.
200     *
201     * @param array       $item     The raw data item (merged original + comparison).
202     * @param string|null $interval Optional time interval for formatting.
203     * @return array The formatted row with comparison fields.
204     */
205    public function format_row_with_comparison( array $item, ?string $interval = null ): array {
206        $row = parent::format_row_with_comparison( $item, $interval );
207
208        $prefix = Report_Data_Fetcher::COMPARISON_INDEX_PREFIX;
209        $key    = $prefix . 'tax_code';
210        if ( isset( $row['tax_code'] ) && isset( $row[ $key ] ) ) {
211            $row[ $key ] = $row['tax_code'];
212        }
213
214        return $row;
215    }
216
217    /**
218     * Get the matching field for comparison data alignment.
219     *
220     * Tax Rate Breakdown is a ranked report, so comparison data should be matched by tax_rate_id.
221     *
222     * @return string|null
223     */
224    public function get_matching_field(): ?string {
225        return 'tax_rate_id';
226    }
227
228    /**
229     * Get the identifying fields that should be preserved in comparison data.
230     *
231     * When a tax rate exists in the original period but not in the comparison period,
232     * the tax code should still be shown for clarity. tax_rate_code is the field the
233     * taxes/breakdown endpoint actually returns (name/country/state/priority are not
234     * part of its schema).
235     *
236     * @return array Array of field names to preserve.
237     */
238    public function get_identifying_fields(): array {
239        return array( 'tax_rate_code' );
240    }
241
242    /**
243     * Get the field name to check for emptiness.
244     *
245     * Enables parent empty row handling: rows with an empty tax code are either skipped
246     * or included with the customizable empty row label (e.g. "Unassigned"). Uses
247     * tax_rate_code, the field the endpoint returns and the one displayed as the tax code.
248     *
249     * @return array The field names to check.
250     */
251    public function get_empty_row_check_field() {
252        return array( 'tax_rate_code' );
253    }
254
255    /**
256     * Apply the custom empty row label to the formatted row.
257     *
258     * Overridden because the API field is 'name' but the CSV column is 'tax_code'.
259     *
260     * @param array $row          The formatted row data.
261     * @param array $check_fields The field names that were empty.
262     * @return array The row with custom label applied.
263     */
264    protected function apply_empty_row_label( array $row, array $check_fields ): array { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Signature required by the report controller interface.
265        $custom_label = $this->get_empty_row_label();
266        if ( isset( $row['tax_code'] ) && '' === $row['tax_code'] ) {
267            $row['tax_code'] = $custom_label;
268        }
269        return $row;
270    }
271}