PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.21
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.21
1.4.21 1.4.20 1.4.19 1.4.18 1.4.17 1.4.16 1.4.15 1.4.14 1.4.13 1.4.12 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.54 1.3.53 1.3.52 All 184 releases
disco / app / Intents / CategoryBogo / CategoryBogoCart.php

CategoryBogoCart.php in Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO 1.4.21, at app/Intents/CategoryBogo/CategoryBogoCart.php

301 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Category BOGO cart reader / writer.
4 *
5 * @package Disco
6 * @subpackage \App\Intents\CategoryBogo
7 */
8
9 namespace Disco\App\Intents\CategoryBogo;
10
11 use Disco\App\Calc\CalcFactory;
12 use Disco\App\Utility\Helper;
13 use Disco\App\Utility\Value;
14
15 /**
16 * Reads the cart and marks the free quantity on the lines the customer added.
17 *
18 * Reading merges the cart into one entry per "cart product key"
19 * (`productId:variationId`), keeping the cart item keys that make it up. The buy
20 * quantity is therefore always the real cart quantity, so recalculation cannot
21 * oscillate.
22 *
23 * Writing NEVER adds a line to the cart and NEVER removes one: the customer owns
24 * what is in their cart. The engine only writes meta on the lines they already
25 * added:
26 *
27 * - whole line free → `is_free_product`, priced at zero by the cart hook.
28 * - part of a line free → `disco_category_bogo_free_quantity`, and the cart hook
29 * charges only the paid units of that line.
30 * - no entitlement → both keys cleared, the line goes back to its normal price.
31 *
32 * Quantities are never touched, and meta is written straight onto
33 * `cart_contents` because the WooCommerce setters recalculate totals, which
34 * would recurse from inside `woocommerce_before_calculate_totals`.
35 *
36 * @package Disco
37 * @subpackage Disco\App\Intents\CategoryBogo
38 * @category Intention
39 */
40 class CategoryBogoCart {
41
42 /**
43 * Cart item meta key marking a line as managed by this engine.
44 */
45 public const FREE_ITEM_FLAG = 'disco_category_bogo_free_item';
46
47 /**
48 * Cart item meta key holding how many units of the line are free.
49 */
50 public const FREE_QUANTITY_META = 'disco_category_bogo_free_quantity';
51
52 /**
53 * Merge the cart into one entry per cart product key, in cart order.
54 *
55 * @param \WC_Cart $cart Cart object.
56 * @return array<string, array<string, mixed>>
57 */
58 public function get_merged_cart_products( \WC_Cart $cart ): array {
59 $cart_products = array();
60 $cart_items = $cart->get_cart();
61
62 if ( ! is_array( $cart_items ) ) {
63 return $cart_products;
64 }
65
66 foreach ( $cart_items as $cart_item_key => $cart_item ) {
67 $cart_products = $this->merge_cart_item_into_product( $cart_products, (string) $cart_item_key, (array) $cart_item );
68 }
69
70 return $cart_products;
71 }
72
73 /**
74 * Mark a product's entitled free quantity across the lines that hold it.
75 *
76 * The entitlement is handed to the lines in cart order: a line is marked
77 * wholly free while the entitlement still covers it, the line it runs out on
78 * is marked partly free, and every later line goes back to paid.
79 *
80 * @param \WC_Cart $cart Cart object.
81 * @param array<string, mixed> $cart_product Merged cart product.
82 * @param int $free_quantity Entitled free quantity.
83 */
84 public function sync_cart_product_free_quantity( \WC_Cart $cart, array $cart_product, int $free_quantity ): void {
85 $remaining_free_quantity = max( 0, $free_quantity );
86 $line_quantities = $this->get_cart_line_quantities( $cart_product );
87
88 foreach ( $line_quantities as $cart_item_key => $line_quantity ) {
89 $line_free_quantity = (int) min( $line_quantity, $remaining_free_quantity );
90
91 $remaining_free_quantity -= $line_free_quantity;
92
93 if ( $line_free_quantity <= 0 ) {
94 $effective_product_id = Value::to_int( $cart_product['effective_product_id'] ?? 0 );
95
96 $this->mark_cart_line_as_paid( $cart, (string) $cart_item_key, $effective_product_id );
97
98 continue;
99 }
100
101 if ( $line_free_quantity >= $line_quantity ) {
102 $this->mark_cart_line_fully_free( $cart, (string) $cart_item_key, $line_quantity );
103
104 continue;
105 }
106
107 $this->mark_cart_line_partly_free( $cart, (string) $cart_item_key, $line_free_quantity );
108 }
109 }
110
111 /**
112 * Cart product keys whose product sits in one of the given categories.
113 *
114 * @param array<string, array<string, mixed>> $cart_products Cart products by product key.
115 * @param array<int, int> $category_ids Category ids.
116 * @return array<string, bool> Keyed by cart product key.
117 */
118 public function get_product_keys_in_categories( array $cart_products, array $category_ids ): array {
119 $matching_product_keys = array();
120
121 foreach ( $cart_products as $product_key => $cart_product ) {
122 if ( ! $this->is_product_in_categories( $cart_product, $category_ids ) ) {
123 continue;
124 }
125
126 $matching_product_keys[ (string) $product_key ] = true;
127 }
128
129 return $matching_product_keys;
130 }
131
132 /**
133 * Whether a merged cart product sits in one of the given categories.
134 *
135 * @param array<string, mixed> $cart_product Merged cart product.
136 * @param array<int, int> $category_ids Category ids.
137 */
138 public function is_product_in_categories( array $cart_product, array $category_ids ): bool {
139 if ( empty( $category_ids ) ) {
140 return false;
141 }
142
143 $effective_product_id = Value::to_int( $cart_product['effective_product_id'] ?? 0 );
144
145 return (bool) Helper::is_in_category( $effective_product_id, $category_ids );
146 }
147
148 /**
149 * Add one cart item to its merged product entry.
150 *
151 * @param array<string, array<string, mixed>> $cart_products Merged products so far.
152 * @param string $cart_item_key Cart item key.
153 * @param array<string, mixed> $cart_item Cart item.
154 * @return array<string, array<string, mixed>>
155 */
156 private function merge_cart_item_into_product( array $cart_products, string $cart_item_key, array $cart_item ): array {
157 $product_id = Value::to_int( $cart_item['product_id'] ?? 0 );
158 $variation_id = Value::to_int( $cart_item['variation_id'] ?? 0 );
159
160 if ( $product_id <= 0 && $variation_id <= 0 ) {
161 return $cart_products;
162 }
163
164 $product_key = $product_id . ':' . $variation_id;
165
166 if ( ! isset( $cart_products[ $product_key ] ) ) {
167 $cart_products[ $product_key ] = $this->create_cart_product_entry( $product_id, $variation_id, $cart_item );
168 }
169
170 $merged_product = $cart_products[ $product_key ];
171 $line_quantity = Value::to_int( $cart_item['quantity'] ?? 0 );
172 $merged_quantity = Value::to_int( $merged_product['quantity'] );
173 $line_quantities = array();
174
175 if ( is_array( $merged_product['line_quantities'] ) ) {
176 $line_quantities = $merged_product['line_quantities'];
177 }
178
179 $line_quantities[ $cart_item_key ] = $line_quantity;
180
181 $merged_product['quantity'] = $merged_quantity + $line_quantity;
182 $merged_product['line_quantities'] = $line_quantities;
183 $cart_products[ $product_key ] = $merged_product;
184
185 return $cart_products;
186 }
187
188 /**
189 * Build a fresh merged product entry for a cart item.
190 *
191 * `effective_product_id` is the id the engine prices and categorises by: the variation
192 * id for a variation, the product id otherwise.
193 *
194 * @param int $product_id Product id.
195 * @param int $variation_id Variation id.
196 * @param array<string, mixed> $cart_item Cart item.
197 * @return array<string, mixed>
198 */
199 private function create_cart_product_entry( int $product_id, int $variation_id, array $cart_item ): array {
200 return array(
201 'effective_product_id' => $variation_id > 0 ? $variation_id : $product_id,
202 'product_id' => $product_id,
203 'variation_id' => $variation_id,
204 'quantity' => 0,
205 'price' => (float) CalcFactory::get_price( $cart_item ),
206 'line_quantities' => array(),
207 );
208 }
209
210 /**
211 * Quantity of every cart line holding a merged product, in cart order.
212 *
213 * @param array<string, mixed> $cart_product Merged cart product.
214 * @return array<string, int>
215 */
216 private function get_cart_line_quantities( array $cart_product ): array {
217 if ( empty( $cart_product['line_quantities'] ) || ! is_array( $cart_product['line_quantities'] ) ) {
218 return array();
219 }
220
221 $line_quantities = array();
222
223 foreach ( $cart_product['line_quantities'] as $cart_item_key => $line_quantity ) {
224 $line_quantities[ (string) $cart_item_key ] = Value::to_int( $line_quantity );
225 }
226
227 return $line_quantities;
228 }
229
230 /**
231 * Mark a whole cart line as free.
232 *
233 * @param \WC_Cart $cart Cart object.
234 * @param string $cart_item_key Cart item key.
235 * @param int $free_quantity Units of the line that are free.
236 */
237 private function mark_cart_line_fully_free( \WC_Cart $cart, string $cart_item_key, int $free_quantity ): void {
238 if ( ! isset( $cart->cart_contents[ $cart_item_key ] ) ) {
239 return;
240 }
241
242 $cart->cart_contents[ $cart_item_key ]['is_free_product'] = true;
243 $cart->cart_contents[ $cart_item_key ][ self::FREE_ITEM_FLAG ] = true;
244 $cart->cart_contents[ $cart_item_key ][ self::FREE_QUANTITY_META ] = $free_quantity;
245 }
246
247 /**
248 * Mark part of a cart line as free.
249 *
250 * `is_free_product` is deliberately not set: the line still has paid units,
251 * so the cart hook prices it down instead of zeroing it.
252 *
253 * @param \WC_Cart $cart Cart object.
254 * @param string $cart_item_key Cart item key.
255 * @param int $free_quantity Units of the line that are free.
256 */
257 private function mark_cart_line_partly_free( \WC_Cart $cart, string $cart_item_key, int $free_quantity ): void {
258 if ( ! isset( $cart->cart_contents[ $cart_item_key ] ) ) {
259 return;
260 }
261
262 unset( $cart->cart_contents[ $cart_item_key ]['is_free_product'] );
263
264 $cart->cart_contents[ $cart_item_key ][ self::FREE_ITEM_FLAG ] = true;
265 $cart->cart_contents[ $cart_item_key ][ self::FREE_QUANTITY_META ] = $free_quantity;
266 }
267
268 /**
269 * Clear every free marker from a cart line.
270 *
271 * The product object is refreshed because the line's price may already have
272 * been zeroed or reduced earlier in the request; reusing it would keep the
273 * discount after the entitlement is gone.
274 *
275 * @param \WC_Cart $cart Cart object.
276 * @param string $cart_item_key Cart item key.
277 * @param int $effective_product_id Product / variation id.
278 */
279 private function mark_cart_line_as_paid( \WC_Cart $cart, string $cart_item_key, int $effective_product_id ): void {
280 if ( ! isset( $cart->cart_contents[ $cart_item_key ] ) ) {
281 return;
282 }
283
284 unset(
285 $cart->cart_contents[ $cart_item_key ]['is_free_product'],
286 $cart->cart_contents[ $cart_item_key ]['free_product_note'],
287 $cart->cart_contents[ $cart_item_key ][ self::FREE_ITEM_FLAG ],
288 $cart->cart_contents[ $cart_item_key ][ self::FREE_QUANTITY_META ]
289 );
290
291 $product = wc_get_product( $effective_product_id );
292
293 if ( ! $product instanceof \WC_Product ) {
294 return;
295 }
296
297 $cart->cart_contents[ $cart_item_key ]['data'] = $product;
298 }
299
300 }
301