PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.1
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.1
3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 2.8.17 2.8.18 All 259 releases
wpfunnels / includes / core / MCP / Tools / OfferTools.php

OfferTools.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.1, at includes/core/MCP/Tools/OfferTools.php

492 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OfferTools — checkout-side order bump abilities (Free domain).
4 *
5 * Order bumps live as a single `order-bump-settings` post meta array on the
6 * checkout step, one entry per bump (keyed by numeric index). This wraps the
7 * exact same meta model and type-factory hooks
8 * `includes/core/rest-api/Controllers/class-orderbump-controller.php` uses,
9 * so the copilot and the checkout step editor UI never drift apart.
10 *
11 * Post-purchase upsell/downsell offers are a separate, Pro-only domain — see
12 * `wpfunnels-pro/includes/core/mcp/Tools/PostPurchaseOfferTools.php`.
13 *
14 * @package WPFunnels\MCP
15 * @since 3.13.0
16 */
17
18 namespace WPFunnels\MCP\Tools;
19
20 defined( 'ABSPATH' ) || exit;
21
22 use WPFunnels\MCP\Helpers\MCPHelper;
23 use WPFunnels\Rest\Controllers\OrderBumpController;
24
25 /**
26 * Class OfferTools
27 */
28 class OfferTools {
29
30 /**
31 * Post meta key order bump settings are stored under.
32 */
33 private const META_KEY = 'order-bump-settings';
34
35 /**
36 * Ability definitions for this domain.
37 *
38 * @return array
39 */
40 public static function definitions() {
41 return [
42 'wpfunnels/get-order-bump' => [
43 'label' => __( 'Get Order Bump', 'wpfnl' ),
44 'description' => 'List the order bumps configured on a checkout step: product, quantity, discount (including any attached coupon) and any conditional display rules, plus whether each is enabled.',
45 'input_schema' => [
46 'type' => 'object',
47 'properties' => [
48 'step_id' => [
49 'type' => 'integer',
50 'description' => 'Checkout step post ID.',
51 ],
52 ],
53 'required' => [ 'step_id' ],
54 ],
55 'execute_callback' => [ __CLASS__, 'getOrderBump' ],
56 'permission_callback' => MCPHelper::currentUserCan(),
57 'annotations' => [ 'readonly' ],
58 ],
59 'wpfunnels/upsert-order-bump' => [
60 'label' => __( 'Add or Update Order Bump', 'wpfnl' ),
61 'description' => 'Add a new order bump to a checkout step, or update an existing one by index (from get-order-bump). Other order bumps on the same step are left untouched — this is a read-modify-write on one entry, not a full replace.',
62 'input_schema' => [
63 'type' => 'object',
64 'properties' => [
65 'step_id' => [
66 'type' => 'integer',
67 'description' => 'Checkout step post ID.',
68 ],
69 'index' => [
70 'type' => 'integer',
71 'description' => 'Index of an existing order bump to update, from get-order-bump. Omit to add a new one.',
72 ],
73 'product_id' => [
74 'type' => 'integer',
75 'description' => 'WooCommerce product ID offered as the bump.',
76 ],
77 'quantity' => [
78 'type' => 'integer',
79 'description' => 'Quantity added when the bump is accepted.',
80 'default' => 1,
81 ],
82 'discount_type' => [
83 'type' => 'string',
84 'description' => 'How the discount is applied. "coupon" attaches an existing WooCommerce coupon (find it first with wpfunnels/search-coupons) rather than a raw percentage/price.',
85 'enum' => [ 'discount-percentage', 'discount-price', 'coupon', 'none' ],
86 ],
87 'discount_value' => [
88 'type' => 'number',
89 'description' => 'Discount percentage (0-100) or fixed amount, matching discount_type. Not used when discount_type is "coupon" — use coupon_code instead.',
90 ],
91 'coupon_code' => [
92 'type' => 'string',
93 'description' => 'Code of an existing WooCommerce coupon to attach when discount_type is "coupon". Find it with wpfunnels/search-coupons — WPFunnels never creates coupons itself, only attaches existing ones.',
94 ],
95 'enabled' => [
96 'type' => 'boolean',
97 'description' => 'Whether the bump is active.',
98 'default' => true,
99 ],
100 'conditions' => [
101 'type' => 'object',
102 'description' => 'Conditional display/targeting rules for the bump (stored as the entry\'s ruleSettings). When enabled, the bump is only shown if the cart matches the rule groups.',
103 'properties' => [
104 'enabled' => [
105 'type' => 'boolean',
106 'description' => 'Whether conditional display is active. When false (default) the bump always shows.',
107 ],
108 'ruleGroups' => [
109 'type' => 'array',
110 'description' => 'Groups of conditions, OR-ed together. Each group\'s own conditions are AND-ed together.',
111 'items' => [
112 'type' => 'object',
113 'properties' => [
114 'name' => [
115 'type' => 'string',
116 'description' => 'Label for the rule group.',
117 ],
118 'conditions' => [
119 'type' => 'array',
120 'items' => [
121 'type' => 'object',
122 'properties' => [
123 'field' => [
124 'type' => 'string',
125 'description' => 'What the condition matches against.',
126 'enum' => [
127 'no_rules',
128 'cart_total',
129 'cart_subtotal',
130 'cart_item_count',
131 'cart_items_quantity',
132 'cart_items',
133 'item_categories',
134 'item_tags',
135 'cart_coupons',
136 'shipping_method',
137 'shipping_country',
138 'billing_country',
139 'checkout_page',
140 ],
141 ],
142 'operator' => [
143 'type' => 'string',
144 'description' => 'Comparison operator. Numeric fields use ==, !=, >, <, >=, <=; list fields (cart_items, item_categories, item_tags, cart_coupons, etc.) use matches_any, matches_all, matches_none.',
145 ],
146 'value' => [
147 'description' => 'Comparison value: a number/string for numeric fields, or an array of IDs/codes for list fields.',
148 ],
149 ],
150 'required' => [ 'field', 'operator' ],
151 ],
152 ],
153 ],
154 ],
155 ],
156 ],
157 ],
158 ],
159 'required' => [ 'step_id' ],
160 ],
161 'execute_callback' => [ __CLASS__, 'upsertOrderBump' ],
162 'permission_callback' => MCPHelper::currentUserCan(),
163 'annotations' => [ 'destructive' ],
164 ],
165 'wpfunnels/search-coupons' => [
166 'label' => __( 'Search Coupons', 'wpfnl' ),
167 'description' => 'Search existing WooCommerce coupons by code, for attaching to an order bump as its discount mechanism (discount_type "coupon" on wpfunnels/upsert-order-bump). WPFunnels never creates coupons — this only finds ones that already exist.',
168 'input_schema' => [
169 'type' => 'object',
170 'properties' => [
171 'search' => [
172 'type' => 'string',
173 'description' => 'Search term matched against coupon codes.',
174 ],
175 ],
176 'required' => [ 'search' ],
177 ],
178 'execute_callback' => [ __CLASS__, 'searchCoupons' ],
179 'permission_callback' => MCPHelper::currentUserCan(),
180 'annotations' => [ 'readonly' ],
181 ],
182 'wpfunnels/delete-order-bump' => [
183 'label' => __( 'Delete Order Bump', 'wpfnl' ),
184 'description' => 'Remove one order bump entry from a checkout step by index. Other order bumps on the step are left untouched.',
185 'input_schema' => [
186 'type' => 'object',
187 'properties' => [
188 'step_id' => [
189 'type' => 'integer',
190 'description' => 'Checkout step post ID.',
191 ],
192 'index' => [
193 'type' => 'integer',
194 'description' => 'Index of the order bump to remove, from get-order-bump.',
195 ],
196 ],
197 'required' => [ 'step_id', 'index' ],
198 ],
199 'execute_callback' => [ __CLASS__, 'deleteOrderBump' ],
200 'permission_callback' => MCPHelper::currentUserCan(),
201 'annotations' => [ 'destructive' ],
202 ],
203 'wpfunnels/calculate-offer-discount' => [
204 'label' => __( 'Calculate Offer Discount', 'wpfnl' ),
205 'description' => 'Calculate the discounted price for a given discount type/value against a product price — use before proposing a discount so the number shown to the site owner is accurate.',
206 'input_schema' => [
207 'type' => 'object',
208 'properties' => [
209 'discount_type' => [
210 'type' => 'string',
211 'enum' => [ 'discount-percentage', 'discount-price' ],
212 ],
213 'discount_value' => [ 'type' => 'number' ],
214 'product_price' => [ 'type' => 'number' ],
215 ],
216 'required' => [ 'discount_type', 'discount_value', 'product_price' ],
217 ],
218 'execute_callback' => [ __CLASS__, 'calculateOfferDiscount' ],
219 'permission_callback' => MCPHelper::currentUserCan(),
220 'annotations' => [ 'readonly' ],
221 ],
222 ];
223 }
224
225 /**
226 * Read the raw order-bump-settings array for a step.
227 *
228 * @param int $step_id Step id.
229 * @return array
230 */
231 private static function readSettings( $step_id ) {
232 $settings = get_post_meta( $step_id, self::META_KEY, true );
233 return is_array( $settings ) ? $settings : [];
234 }
235
236 /**
237 * Persist the order-bump-settings array for a step, running it through the
238 * same funnel-type factory hook the REST controller applies, and firing
239 * the same actions so downstream code (webhooks, GBF sync) still fires.
240 *
241 * @param int $step_id Step id.
242 * @param array $settings Full settings array.
243 * @return void
244 */
245 private static function writeSettings( $step_id, array $settings ) {
246 $funnel_id = (int) get_post_meta( $step_id, '_funnel_id', true );
247 $type = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true );
248 $type = $type ? $type : 'wc';
249
250 if ( class_exists( '\Wpfnl_Controller_Type_Factory' ) ) {
251 $class_object = \Wpfnl_Controller_Type_Factory::build( $type );
252 if ( $class_object && method_exists( $class_object, 'update_ob_settings' ) ) {
253 $settings = $class_object->update_ob_settings( $settings );
254 }
255 }
256
257 update_post_meta( $step_id, self::META_KEY, $settings );
258 do_action( 'wpfunnels/after_save_order_bump_data', $step_id, $settings );
259 do_action( 'wpfunnels_order_bump_added', $funnel_id, $step_id );
260 }
261
262 /**
263 * Get order bumps for a step.
264 *
265 * @param array $input Tool input.
266 * @return array|\WP_Error
267 */
268 public static function getOrderBump( $input = [] ) {
269 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
270 if ( is_wp_error( $step ) ) {
271 return $step;
272 }
273
274 $step_id = (int) $step->ID;
275 $settings = self::readSettings( $step_id );
276
277 $bumps = [];
278 foreach ( $settings as $index => $entry ) {
279 $bumps[] = [
280 'index' => (int) $index,
281 'enabled' => ! empty( $entry['isEnabled'] ),
282 'product_id' => isset( $entry['product'] ) ? (int) $entry['product'] : 0,
283 'quantity' => isset( $entry['quantity'] ) ? (int) $entry['quantity'] : 1,
284 'discount_type' => isset( $entry['discount_type'] ) ? $entry['discount_type'] : '',
285 'discount_value' => isset( $entry['discount_value'] ) ? $entry['discount_value'] : 0,
286 'coupon_code' => isset( $entry['coupon_code'] ) ? $entry['coupon_code'] : ( isset( $entry['couponName'] ) ? $entry['couponName'] : '' ),
287 'conditions' => isset( $entry['ruleSettings'] ) ? $entry['ruleSettings'] : [],
288 ];
289 }
290
291 return [
292 'step_id' => $step_id,
293 'funnel_id' => (int) get_post_meta( $step_id, '_funnel_id', true ),
294 'order_bumps' => $bumps,
295 'positions' => method_exists( '\WPFunnels\Wpfnl_functions', 'supported_orderbump_position' )
296 ? \WPFunnels\Wpfnl_functions::supported_orderbump_position( $step_id )
297 : [],
298 ];
299 }
300
301 /**
302 * Add or update one order bump entry.
303 *
304 * @param array $input Tool input.
305 * @return array|\WP_Error
306 */
307 public static function upsertOrderBump( $input = [] ) {
308 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
309 if ( is_wp_error( $step ) ) {
310 return $step;
311 }
312
313 $step_id = (int) $step->ID;
314 $settings = self::readSettings( $step_id );
315
316 $index = isset( $input['index'] ) ? (int) $input['index'] : null;
317 $entry = ( null !== $index && isset( $settings[ $index ] ) ) ? $settings[ $index ] : [];
318
319 if ( array_key_exists( 'product_id', $input ) ) {
320 $entry['product'] = (int) $input['product_id'];
321 }
322 if ( array_key_exists( 'quantity', $input ) ) {
323 $entry['quantity'] = max( 1, (int) $input['quantity'] );
324 } elseif ( ! isset( $entry['quantity'] ) ) {
325 $entry['quantity'] = 1;
326 }
327 if ( array_key_exists( 'discount_type', $input ) ) {
328 $entry['discount_type'] = sanitize_text_field( (string) $input['discount_type'] );
329 }
330 if ( array_key_exists( 'discount_value', $input ) ) {
331 $entry['discount_value'] = (float) $input['discount_value'];
332 }
333 if ( array_key_exists( 'coupon_code', $input ) ) {
334 $coupon_code = sanitize_text_field( (string) $input['coupon_code'] );
335 $entry['coupon_code'] = $coupon_code;
336 // Mirrors the key the checkout-step admin UI (Coupon.vue) itself
337 // writes to the entry, so a non-MCP save of this bump still shows
338 // the attached coupon's name.
339 $entry['couponName'] = $coupon_code;
340 }
341 if ( array_key_exists( 'conditions', $input ) ) {
342 $entry['ruleSettings'] = is_array( $input['conditions'] ) ? $input['conditions'] : [];
343 }
344 if ( array_key_exists( 'enabled', $input ) ) {
345 $entry['isEnabled'] = (bool) $input['enabled'];
346 } elseif ( ! isset( $entry['isEnabled'] ) ) {
347 $entry['isEnabled'] = true;
348 }
349
350 if ( empty( $entry['product'] ) ) {
351 return MCPHelper::error( 'missing_product', 'An order bump needs a product_id. Use wpfunnels/search-products to find one.' );
352 }
353
354 if ( null !== $index && isset( $settings[ $index ] ) ) {
355 $settings[ $index ] = $entry;
356 $action = 'updated';
357 } else {
358 $settings[] = $entry;
359 $index = array_key_last( $settings );
360 $action = 'created';
361 }
362
363 self::writeSettings( $step_id, $settings );
364
365 return [
366 'success' => true,
367 'action' => $action,
368 'step_id' => $step_id,
369 'index' => $index,
370 'entry' => $entry,
371 ];
372 }
373
374 /**
375 * Remove one order bump entry.
376 *
377 * @param array $input Tool input.
378 * @return array|\WP_Error
379 */
380 public static function deleteOrderBump( $input = [] ) {
381 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
382 if ( is_wp_error( $step ) ) {
383 return $step;
384 }
385
386 $step_id = (int) $step->ID;
387 $index = isset( $input['index'] ) ? (int) $input['index'] : -1;
388 $settings = self::readSettings( $step_id );
389
390 if ( ! isset( $settings[ $index ] ) ) {
391 return MCPHelper::error( 'order_bump_not_found', sprintf( 'No order bump at index %d on step %d.', $index, $step_id ) );
392 }
393
394 unset( $settings[ $index ] );
395 $settings = array_values( $settings );
396
397 self::writeSettings( $step_id, $settings );
398
399 return [
400 'success' => true,
401 'step_id' => $step_id,
402 'deleted_index' => $index,
403 'remaining' => count( $settings ),
404 ];
405 }
406
407 /**
408 * Search existing WooCommerce coupons by code.
409 *
410 * Reimplements the query core of
411 * `admin/modules/steps/checkout/class-wpfnl-checkout.php::fetch_coupons()`
412 * (its AJAX action `order_bump_search_coupons`, used by Coupon.vue) without
413 * the `$_GET`/`check_ajax_referer()` plumbing that method needs as an AJAX
414 * handler.
415 *
416 * @param array $input Tool input.
417 * @return array|\WP_Error
418 */
419 public static function searchCoupons( $input = [] ) {
420 if ( ! function_exists( 'wc_get_coupon_types' ) ) {
421 return MCPHelper::error( 'woocommerce_inactive', 'WooCommerce must be active to search coupons.' );
422 }
423
424 $term = isset( $input['search'] ) ? sanitize_text_field( (string) $input['search'] ) : '';
425 if ( '' === $term ) {
426 return [
427 'search' => $term,
428 'coupons' => [],
429 ];
430 }
431
432 $args = [
433 'posts_per_page' => -1,
434 'orderby' => 'title',
435 'order' => 'asc',
436 'post_type' => 'shop_coupon',
437 'post_status' => 'publish',
438 's' => $term,
439 ];
440
441 $coupons = get_posts( $args );
442 $discount_types = wc_get_coupon_types();
443 $results = [];
444
445 if ( $coupons ) {
446 foreach ( $coupons as $coupon ) {
447 $discount_type = get_post_meta( $coupon->ID, 'discount_type', true );
448 if ( empty( $discount_types[ $discount_type ] ) ) {
449 continue;
450 }
451
452 $results[] = [
453 'id' => (int) $coupon->ID,
454 'code' => $coupon->post_title,
455 'amount' => get_post_meta( $coupon->ID, 'coupon_amount', true ),
456 'discount_type' => $discount_type,
457 ];
458 }
459 }
460
461 return [
462 'search' => $term,
463 'coupons' => $results,
464 ];
465 }
466
467 /**
468 * Calculate a discounted price.
469 *
470 * @param array $input Tool input.
471 * @return array|\WP_Error
472 */
473 public static function calculateOfferDiscount( $input = [] ) {
474 $discount_type = isset( $input['discount_type'] ) ? (string) $input['discount_type'] : '';
475 $discount_value = isset( $input['discount_value'] ) ? (float) $input['discount_value'] : 0;
476 $product_price = isset( $input['product_price'] ) ? (float) $input['product_price'] : 0;
477
478 if ( ! class_exists( '\WPFunnels\Rest\Controllers\OrderBumpController' ) ) {
479 return MCPHelper::error( 'controller_unavailable', 'The order bump controller is not available.' );
480 }
481
482 $controller = new OrderBumpController();
483 $price = $controller->calculate_custom_price( $discount_type, $discount_value, $product_price );
484
485 return [
486 'original_price' => $product_price,
487 'discounted_price' => (float) $price,
488 'savings' => round( $product_price - (float) $price, 2 ),
489 ];
490 }
491 }
492