PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / Services / Variable_Price_Range.php

Variable_Price_Range.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.16, at includes/Services/Variable_Price_Range.php

288 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Canonical variable-product price range.
4 *
5 * @package WCPOS\WooCommercePOS\Services
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 use WC_Product_Variable;
11 use WCPOS\WooCommercePOS\Sync\Pos_Visibility;
12
13 /**
14 * THE variable-product price range.
15 *
16 * A variable product's min/max price is the number a cashier reads off the till,
17 * and it used to be computed twice: once by the V1 products controller (which
18 * persists it to `_woocommerce_pos_variable_prices` postmeta) and once by the sync
19 * read stampers (which inject it into the served payload). The two implementations
20 * disagreed on which WooCommerce filters run and on when a sale price counts, so
21 * the same product could show two different ranges depending on which lane served
22 * it. This class is the single computation; the two consumers are thin adapters
23 * that differ ONLY in how the numbers are rendered on the wire.
24 *
25 * It lives in Services rather than Sync because it is a WooCommerce pricing fact
26 * shared by the V1 REST surface and the V2 sync surface — V1 must not have to
27 * depend on the sync subsystem to price a product.
28 *
29 * Canonical semantics (all lanes):
30 *
31 * - Children come from WooCommerce's own visible-children resolution (which applies
32 * the store's hide-hidden / hide-out-of-stock rules), MINUS the WCPOS `online_only`
33 * variations, which are hidden from the POS and must not leak a price into the range.
34 * - Prices are read per child in EDIT context and pushed through WooCommerce's six
35 * variation-price filters — the three per-variation ones
36 * (`woocommerce_variation_prices_{price,regular_price,sale_price}`) and the three
37 * min/max ones (`woocommerce_get_variation_{price,regular_price,sale_price}`).
38 * Extensions that reprice variations (WCPOS Pro's store pricing among them) hook
39 * exactly these.
40 * - Children are read DIRECTLY, never via `get_variation_prices()`, whose
41 * `wc_var_prices_*` transient goes stale the moment a child price changes
42 * (gap-analysis §4.3) — the stale range this module exists to prevent.
43 * - A child with no active price is skipped entirely.
44 * - A sale price counts ONLY when the sale is ACTIVE: it must differ from the
45 * child's regular price AND equal the child's current price. WooCommerce leaves
46 * `_sale_price` on a variation after a scheduled sale ends, so a non-empty sale
47 * price is not proof of a sale.
48 *
49 * Two renderings, selected by `$format`:
50 *
51 * - FORMAT_DECIMAL — what V1 persists to postmeta: every value run through
52 * `wc_format_decimal()` at the store's price precision, all three sub-ranges
53 * always present, an absent sub-range rendered as empty strings.
54 * - FORMAT_RAW — what the sync lane puts on the wire: the child's own price
55 * strings, untouched, so no float round-trip mangles decimal precision.
56 *
57 * The active-sale test is always made on FORMATTED values in both renderings, so
58 * `'5.0'` and `'5.00'` cannot disagree about whether a sale is running.
59 */
60 final class Variable_Price_Range {
61 /**
62 * Render values through `wc_format_decimal()` (the V1 / postmeta shape).
63 */
64 public const FORMAT_DECIMAL = 'decimal';
65
66 /**
67 * Render the child's own unformatted price strings (the sync wire shape).
68 */
69 public const FORMAT_RAW = 'raw';
70
71 /**
72 * The price sub-ranges, in wire order.
73 */
74 private const FIELDS = array( 'price', 'regular_price', 'sale_price' );
75
76 /**
77 * Compute the canonical price range for a variable product.
78 *
79 * @param WC_Product_Variable $product Variable product.
80 * @param string $format One of FORMAT_DECIMAL or FORMAT_RAW.
81 *
82 * @return array{
83 * ranges: array<string, array{min: string, max: string}>,
84 * minimum_price: string,
85 * has_prices: bool
86 * } `ranges` always carries all three sub-ranges, empty ones as empty strings.
87 * `minimum_price` is the filtered minimum BEFORE any decimal formatting — the
88 * value a caller should use for the parent's own `price` field, so it can apply
89 * the request's own `dp` precision. `has_prices` is false when no visible child
90 * carries a price at all.
91 */
92 public static function for( WC_Product_Variable $product, string $format = self::FORMAT_DECIMAL ): array {
93 $as_decimal = self::FORMAT_DECIMAL === $format;
94 $decimals = \function_exists( 'wc_get_price_decimals' ) ? wc_get_price_decimals() : 2;
95 $collected = array_fill_keys( self::FIELDS, array() );
96
97 $child_ids = self::visible_child_ids( $product );
98 // One bulk posts+postmeta prime for the whole family: without it every
99 // wc_get_product() below pays its own single-id posts + postmeta pair —
100 // ~13 queries per variable product on a catalog page (wcpos#1569, the
101 // dominant shape in the dev-next slow log). Prices live in postmeta;
102 // term caches are not read here.
103 if ( \count( $child_ids ) > 1 ) {
104 _prime_post_caches( $child_ids, false, true );
105 }
106
107 foreach ( $child_ids as $variation_id ) {
108 $variation = wc_get_product( $variation_id );
109 if ( ! $variation ) {
110 continue;
111 }
112
113 $price = apply_filters(
114 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- intentionally invoking WC core filter.
115 'woocommerce_variation_prices_price',
116 $variation->get_price( 'edit' ),
117 $variation,
118 $product
119 );
120
121 if ( '' === $price || null === $price ) {
122 continue;
123 }
124
125 $regular_price = apply_filters(
126 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- intentionally invoking WC core filter.
127 'woocommerce_variation_prices_regular_price',
128 $variation->get_regular_price( 'edit' ),
129 $variation,
130 $product
131 );
132 $sale_price = apply_filters(
133 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- intentionally invoking WC core filter.
134 'woocommerce_variation_prices_sale_price',
135 $variation->get_sale_price( 'edit' ),
136 $variation,
137 $product
138 );
139
140 $formatted_price = wc_format_decimal( $price, $decimals );
141 $formatted_regular_price = wc_format_decimal( $regular_price, $decimals );
142 $formatted_sale_price = wc_format_decimal( $sale_price, $decimals );
143
144 // The active price keeps the child's own string in BOTH renderings; the
145 // decimal rendering formats it once at the end, after the min/max filter,
146 // so a filtered minimum is never rounded twice.
147 $collected['price'][ $variation_id ] = (string) $price;
148
149 if ( $as_decimal ) {
150 $collected['regular_price'][ $variation_id ] = $formatted_regular_price;
151 } elseif ( '' !== $regular_price && null !== $regular_price ) {
152 $collected['regular_price'][ $variation_id ] = (string) $regular_price;
153 }
154
155 if ( '' !== $sale_price && null !== $sale_price
156 && $formatted_sale_price !== $formatted_regular_price
157 && $formatted_sale_price === $formatted_price ) {
158 $collected['sale_price'][ $variation_id ] = $as_decimal ? $formatted_sale_price : (string) $sale_price;
159 }
160 }
161
162 foreach ( $collected as $field => $values ) {
163 asort( $values, SORT_NUMERIC );
164 $collected[ $field ] = $values;
165 }
166
167 $price_range = self::apply_range_filter( 'woocommerce_get_variation_price', self::min_max( $collected['price'] ), $product );
168 $minimum_price = $price_range['min'];
169 if ( $as_decimal ) {
170 $price_range = array(
171 'min' => '' === $price_range['min'] ? '' : wc_format_decimal( $price_range['min'], $decimals ),
172 'max' => '' === $price_range['max'] ? '' : wc_format_decimal( $price_range['max'], $decimals ),
173 );
174 }
175
176 $ranges = array(
177 'price' => $price_range,
178 'regular_price' => self::apply_range_filter(
179 'woocommerce_get_variation_regular_price',
180 self::min_max( $collected['regular_price'] ),
181 $product
182 ),
183 'sale_price' => self::apply_range_filter(
184 'woocommerce_get_variation_sale_price',
185 self::min_max( $collected['sale_price'] ),
186 $product
187 ),
188 );
189
190 return array(
191 'ranges' => $ranges,
192 'minimum_price' => $minimum_price,
193 'has_prices' => self::any_range_populated( $ranges ),
194 );
195 }
196
197 /**
198 * The visible child variation ids, minus the ones hidden from the POS.
199 *
200 * WooCommerce's visible-children resolution applies the store's WEB visibility
201 * rules, NOT the WCPOS `online_only` exclusion — a child hidden from the POS
202 * would otherwise leak its price into the served range. The subtraction is gated
203 * on the feature toggle inside Pos_Visibility, which returns an empty list when
204 * the toggle is off.
205 *
206 * @param WC_Product_Variable $product Variable product.
207 *
208 * @return array<int, int>
209 */
210 private static function visible_child_ids( WC_Product_Variable $product ): array {
211 return ( new Pos_Visibility() )->filter_visible_children( $product->get_visible_children() );
212 }
213
214 /**
215 * Apply a WooCommerce min/max variation price filter to a computed range.
216 *
217 * An empty end is left alone: an absent sub-range must stay absent, and handing
218 * `''` to a repricing filter would invite a `0.00` back.
219 *
220 * @param string $hook_name WooCommerce price range filter name.
221 * @param array{min: string, max: string} $price_range Computed range.
222 * @param WC_Product_Variable $product Variable product.
223 *
224 * @return array{min: string, max: string}
225 */
226 private static function apply_range_filter( string $hook_name, array $price_range, WC_Product_Variable $product ): array {
227 if ( '' !== $price_range['min'] ) {
228 $price_range['min'] = (string) apply_filters(
229 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- intentionally invoking WC core filter.
230 $hook_name,
231 $price_range['min'],
232 $product,
233 'min',
234 false
235 );
236 }
237
238 if ( '' !== $price_range['max'] ) {
239 $price_range['max'] = (string) apply_filters(
240 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- intentionally invoking WC core filter.
241 $hook_name,
242 $price_range['max'],
243 $product,
244 'max',
245 false
246 );
247 }
248
249 return $price_range;
250 }
251
252 /**
253 * Convert a numerically-sorted variation price list to a min/max range.
254 *
255 * @param array<int, string> $prices Prices keyed by variation id, sorted ascending.
256 *
257 * @return array{min: string, max: string}
258 */
259 private static function min_max( array $prices ): array {
260 if ( empty( $prices ) ) {
261 return array(
262 'min' => '',
263 'max' => '',
264 );
265 }
266
267 return array(
268 'min' => (string) reset( $prices ),
269 'max' => (string) end( $prices ),
270 );
271 }
272
273 /**
274 * Whether any sub-range carries a value.
275 *
276 * @param array<string, array{min: string, max: string}> $ranges Computed sub-ranges.
277 */
278 private static function any_range_populated( array $ranges ): bool {
279 foreach ( $ranges as $range ) {
280 if ( '' !== $range['min'] || '' !== $range['max'] ) {
281 return true;
282 }
283 }
284
285 return false;
286 }
287 }
288