PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / trunk
Subscriptions for WooCommerce with Stripe Recurring Payments vtrunk
2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / includes / Api / PlanController.php

PlanController.php in Subscriptions for WooCommerce with Stripe Recurring Payments trunk, at includes/Api/PlanController.php

894 lines 25.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Subscription Plans REST controller (free base).
4 *
5 * CRUD for plan groups, plan terms, and product relations, plus a product
6 * picker for the admin Plans manager. Namespace `wpsubscription/v1`, gated by
7 * `manage_woocommerce`. This is the free plugin's first REST surface; Pro adds
8 * the extra routes (`/plans/detach`, `/plans/migrate`, plan-side bulk attach).
9 *
10 * Admin-only: the storefront and checkout never call these routes - they read
11 * plan data directly via PlanRepository. A REST fault cannot break checkout.
12 *
13 * @package SpringDevs\Subscription\Api
14 */
15
16 namespace SpringDevs\Subscription\Api;
17
18 use SpringDevs\Subscription\Admin\PlanPresenter;
19 use SpringDevs\Subscription\Illuminate\Plans\PlanRepository;
20 use WP_REST_Server;
21 use WP_REST_Request;
22 use WP_Error;
23
24 /**
25 * Plan REST controller.
26 */
27 class PlanController {
28
29 /**
30 * REST namespace.
31 *
32 * @var string
33 */
34 const NS = 'wpsubscription/v1';
35
36 /**
37 * Register all plan routes.
38 *
39 * Called from within `rest_api_init` (see API::register_api), so it does not
40 * hook the action itself.
41 *
42 * @return void
43 */
44 public function register_routes() {
45 $perm = array( $this, 'check_permission' );
46
47 register_rest_route(
48 self::NS,
49 '/plans/groups',
50 array(
51 array(
52 'methods' => WP_REST_Server::READABLE,
53 'callback' => array( $this, 'list_groups' ),
54 'permission_callback' => $perm,
55 ),
56 array(
57 'methods' => WP_REST_Server::CREATABLE,
58 'callback' => array( $this, 'create_group' ),
59 'permission_callback' => $perm,
60 ),
61 )
62 );
63
64 register_rest_route(
65 self::NS,
66 '/plans/groups/(?P<id>\d+)',
67 array(
68 array(
69 'methods' => WP_REST_Server::READABLE,
70 'callback' => array( $this, 'get_group' ),
71 'permission_callback' => $perm,
72 ),
73 array(
74 'methods' => WP_REST_Server::EDITABLE,
75 'callback' => array( $this, 'update_group' ),
76 'permission_callback' => $perm,
77 ),
78 array(
79 'methods' => WP_REST_Server::DELETABLE,
80 'callback' => array( $this, 'delete_group' ),
81 'permission_callback' => $perm,
82 ),
83 )
84 );
85
86 register_rest_route(
87 self::NS,
88 '/plans/terms',
89 array(
90 array(
91 'methods' => WP_REST_Server::CREATABLE,
92 'callback' => array( $this, 'create_term' ),
93 'permission_callback' => $perm,
94 ),
95 )
96 );
97
98 register_rest_route(
99 self::NS,
100 '/plans/terms/(?P<id>\d+)',
101 array(
102 array(
103 'methods' => WP_REST_Server::READABLE,
104 'callback' => array( $this, 'get_term' ),
105 'permission_callback' => $perm,
106 ),
107 array(
108 'methods' => WP_REST_Server::EDITABLE,
109 'callback' => array( $this, 'update_term' ),
110 'permission_callback' => $perm,
111 ),
112 array(
113 'methods' => WP_REST_Server::DELETABLE,
114 'callback' => array( $this, 'delete_term' ),
115 'permission_callback' => $perm,
116 ),
117 )
118 );
119
120 register_rest_route(
121 self::NS,
122 '/plans/relations',
123 array(
124 array(
125 'methods' => WP_REST_Server::CREATABLE,
126 'callback' => array( $this, 'create_relation' ),
127 'permission_callback' => $perm,
128 ),
129 )
130 );
131
132 register_rest_route(
133 self::NS,
134 '/plans/relations/(?P<id>\d+)',
135 array(
136 array(
137 'methods' => WP_REST_Server::EDITABLE,
138 'callback' => array( $this, 'update_relation' ),
139 'permission_callback' => $perm,
140 ),
141 array(
142 'methods' => WP_REST_Server::DELETABLE,
143 'callback' => array( $this, 'delete_relation' ),
144 'permission_callback' => $perm,
145 ),
146 )
147 );
148
149 register_rest_route(
150 self::NS,
151 '/plans/products',
152 array(
153 array(
154 'methods' => WP_REST_Server::READABLE,
155 'callback' => array( $this, 'search_products' ),
156 'permission_callback' => $perm,
157 ),
158 )
159 );
160
161 register_rest_route(
162 self::NS,
163 '/plans/product-onetime/(?P<id>\d+)',
164 array(
165 array(
166 'methods' => WP_REST_Server::EDITABLE,
167 'callback' => array( $this, 'save_product_onetime' ),
168 'permission_callback' => $perm,
169 ),
170 )
171 );
172
173 register_rest_route(
174 self::NS,
175 '/plans/group-products/(?P<id>\d+)',
176 array(
177 array(
178 'methods' => WP_REST_Server::READABLE,
179 'callback' => array( $this, 'group_products_view' ),
180 'permission_callback' => $perm,
181 'args' => array(
182 'id' => array(
183 'validate_callback' => function ( $value ) {
184 return is_numeric( $value );
185 },
186 ),
187 ),
188 ),
189 )
190 );
191
192 register_rest_route(
193 self::NS,
194 '/plans/product-view/(?P<id>\d+)',
195 array(
196 array(
197 'methods' => WP_REST_Server::READABLE,
198 'callback' => array( $this, 'product_plan_view' ),
199 'permission_callback' => $perm,
200 'args' => array(
201 'id' => array(
202 'validate_callback' => function ( $value ) {
203 return is_numeric( $value );
204 },
205 ),
206 ),
207 ),
208 )
209 );
210 }
211
212 /**
213 * Permission check: WooCommerce manager.
214 *
215 * @return bool|WP_Error
216 */
217 public function check_permission() {
218 if ( current_user_can( 'manage_woocommerce' ) ) {
219 return true;
220 }
221
222 return new WP_Error(
223 'rest_forbidden',
224 __( 'You are not allowed to manage subscription plans.', 'subscription' ),
225 array( 'status' => rest_authorization_required_code() )
226 );
227 }
228
229 /**
230 * Read request params, preferring a JSON body over query / form params.
231 *
232 * @param WP_REST_Request $request Request.
233 *
234 * @return array
235 */
236 protected function read_params( WP_REST_Request $request ) {
237 $json = $request->get_json_params();
238
239 return ! empty( $json ) ? $json : $request->get_params();
240 }
241
242 /* ---- Groups ---- */
243
244 /**
245 * GET /plans/groups - list every plan group with its term count.
246 *
247 * @return \WP_REST_Response
248 */
249 public function list_groups() {
250 $groups = PlanRepository::get_groups();
251
252 foreach ( $groups as &$group ) {
253 $plans = PlanRepository::get_plans( $group['id'] );
254 $group['term_count'] = count( $plans );
255 $group['type_key'] = PlanRepository::type_to_string( $group['type'] );
256 }
257 unset( $group );
258
259 return rest_ensure_response( $groups );
260 }
261
262 /**
263 * POST /plans/groups - create a plan group.
264 *
265 * Free is Recurring-only: a non-Recurring type is rejected unless Pro is
266 * active (Pro unlocks Subscribe & Save / Installments).
267 *
268 * @param WP_REST_Request $request Request.
269 *
270 * @return \WP_REST_Response|WP_Error
271 */
272 public function create_group( WP_REST_Request $request ) {
273 $params = $this->read_params( $request );
274
275 $guard = $this->guard_recurring_only( $params );
276 if ( is_wp_error( $guard ) ) {
277 return $guard;
278 }
279
280 $id = PlanRepository::insert_group( $params );
281
282 if ( ! $id ) {
283 return new WP_Error( 'subscrpt_plan_create_failed', __( 'Could not create the plan.', 'subscription' ), array( 'status' => 500 ) );
284 }
285
286 // Seed a default monthly duration (draft) so a new plan opens with a
287 // starting billing term the merchant can edit and publish. Callers that
288 // create their own first duration (e.g. the product-editor wizard) pass
289 // seed_default_term=false to avoid a duplicate.
290 if ( false !== ( $params['seed_default_term'] ?? true ) ) {
291 $this->create_default_monthly_term( $id, $params['type'] ?? 'recurring' );
292 }
293
294 return rest_ensure_response( PlanRepository::get_group_tree( $id ) );
295 }
296
297 /**
298 * Create a default monthly duration, in draft, under a freshly created group.
299 *
300 * @param int $group_id Plan group id.
301 * @param string|int $type Group/term type (recurring|subscribe_save|installments, or its int).
302 * @return void
303 */
304 private function create_default_monthly_term( $group_id, $type ) {
305 $type_int = is_numeric( $type ) ? (int) $type : PlanRepository::type_to_int( $type );
306 $is_installments = PlanRepository::TYPE_MAP['installments'] === $type_int;
307
308 $term = array(
309 'plan_group_id' => (int) $group_id,
310 'title' => __( 'Monthly', 'subscription' ),
311 'type' => $type,
312 'billing_frequency' => 1,
313 'billing_interval' => 3, // Months.
314 'status' => 'draft',
315 );
316
317 if ( $is_installments ) {
318 $term['data'] = array( 'installment_count' => 3 );
319 }
320
321 PlanRepository::insert_plan( $term );
322 }
323
324 /**
325 * GET /plans/groups/{id} - full group tree.
326 *
327 * @param WP_REST_Request $request Request.
328 *
329 * @return \WP_REST_Response|WP_Error
330 */
331 public function get_group( WP_REST_Request $request ) {
332 $group = PlanRepository::get_group_tree( (int) $request['id'] );
333
334 if ( ! $group ) {
335 return $this->not_found();
336 }
337
338 return rest_ensure_response( $group );
339 }
340
341 /**
342 * PUT /plans/groups/{id} - update a plan group.
343 *
344 * @param WP_REST_Request $request Request.
345 *
346 * @return \WP_REST_Response|WP_Error
347 */
348 public function update_group( WP_REST_Request $request ) {
349 $id = (int) $request['id'];
350
351 if ( ! PlanRepository::get_group( $id ) ) {
352 return $this->not_found();
353 }
354
355 $params = $this->read_params( $request );
356
357 $guard = $this->guard_recurring_only( $params );
358 if ( is_wp_error( $guard ) ) {
359 return $guard;
360 }
361
362 PlanRepository::update_group( $id, $params );
363
364 return rest_ensure_response( PlanRepository::get_group_tree( $id ) );
365 }
366
367 /**
368 * DELETE /plans/groups/{id} - delete group + cascade.
369 *
370 * @param WP_REST_Request $request Request.
371 *
372 * @return \WP_REST_Response|WP_Error
373 */
374 public function delete_group( WP_REST_Request $request ) {
375 $id = (int) $request['id'];
376
377 if ( ! PlanRepository::get_group( $id ) ) {
378 return $this->not_found();
379 }
380
381 PlanRepository::delete_group( $id );
382
383 return rest_ensure_response(
384 array(
385 'deleted' => true,
386 'id' => $id,
387 )
388 );
389 }
390
391 /* ---- Terms ---- */
392
393 /**
394 * POST /plans/terms - create a plan term under a group.
395 *
396 * @param WP_REST_Request $request Request.
397 *
398 * @return \WP_REST_Response|WP_Error
399 */
400 public function create_term( WP_REST_Request $request ) {
401 $params = $this->read_params( $request );
402
403 if ( empty( $params['plan_group_id'] ) || ! PlanRepository::get_group( $params['plan_group_id'] ) ) {
404 return new WP_Error( 'subscrpt_plan_group_missing', __( 'A valid plan_group_id is required.', 'subscription' ), array( 'status' => 400 ) );
405 }
406
407 $id = PlanRepository::insert_plan( $params );
408
409 if ( ! $id ) {
410 return new WP_Error( 'subscrpt_term_create_failed', __( 'Could not create the plan term.', 'subscription' ), array( 'status' => 500 ) );
411 }
412
413 // Link products already in the group to the new duration so it shows
414 // on the Products tab for them (inheriting their existing price).
415 PlanRepository::backfill_term_relations( (int) $params['plan_group_id'], $id );
416
417 return rest_ensure_response( PlanRepository::get_plan( $id ) );
418 }
419
420 /**
421 * GET /plans/terms/{id} - single plan term (for edit prefill).
422 *
423 * @param WP_REST_Request $request Request.
424 *
425 * @return \WP_REST_Response|WP_Error
426 */
427 public function get_term( WP_REST_Request $request ) {
428 $term = PlanRepository::get_plan( (int) $request['id'] );
429
430 if ( ! $term ) {
431 return $this->not_found();
432 }
433
434 return rest_ensure_response( $term );
435 }
436
437 /**
438 * PUT /plans/terms/{id} - update a plan term.
439 *
440 * @param WP_REST_Request $request Request.
441 *
442 * @return \WP_REST_Response|WP_Error
443 */
444 public function update_term( WP_REST_Request $request ) {
445 $id = (int) $request['id'];
446
447 if ( ! PlanRepository::get_plan( $id ) ) {
448 return $this->not_found();
449 }
450
451 PlanRepository::update_plan( $id, $this->read_params( $request ) );
452
453 return rest_ensure_response( PlanRepository::get_plan( $id ) );
454 }
455
456 /**
457 * DELETE /plans/terms/{id} - delete a plan term + its relations.
458 *
459 * @param WP_REST_Request $request Request.
460 *
461 * @return \WP_REST_Response|WP_Error
462 */
463 public function delete_term( WP_REST_Request $request ) {
464 $id = (int) $request['id'];
465
466 if ( ! PlanRepository::get_plan( $id ) ) {
467 return $this->not_found();
468 }
469
470 PlanRepository::delete_plan( $id );
471
472 return rest_ensure_response(
473 array(
474 'deleted' => true,
475 'id' => $id,
476 )
477 );
478 }
479
480 /* ---- Relations ---- */
481
482 /**
483 * POST /plans/relations - attach a product to a plan term.
484 *
485 * Free is simple-product only: a variation relation (`vid` != 0) is rejected
486 * unless Pro is active (Pro unlocks per-variation attach).
487 *
488 * @param WP_REST_Request $request Request.
489 *
490 * @return \WP_REST_Response|WP_Error
491 */
492 public function create_relation( WP_REST_Request $request ) {
493 $params = $this->read_params( $request );
494
495 if ( empty( $params['plan_id'] ) || ! PlanRepository::get_plan( $params['plan_id'] ) ) {
496 return new WP_Error( 'subscrpt_plan_missing', __( 'A valid plan_id is required.', 'subscription' ), array( 'status' => 400 ) );
497 }
498
499 if ( empty( $params['oid'] ) ) {
500 return new WP_Error( 'subscrpt_oid_missing', __( 'A product or term id (oid) is required.', 'subscription' ), array( 'status' => 400 ) );
501 }
502
503 if ( ! isset( $params['type'] ) ) {
504 $params['type'] = PlanRepository::REL_PRODUCT;
505 }
506
507 $guard = $this->guard_simple_only( $params );
508 if ( is_wp_error( $guard ) ) {
509 return $guard;
510 }
511
512 $id = PlanRepository::insert_relation( $params );
513
514 if ( ! $id ) {
515 return new WP_Error( 'subscrpt_relation_create_failed', __( 'Could not attach the product.', 'subscription' ), array( 'status' => 500 ) );
516 }
517
518 // Connecting a plan enables the subscription on the product / variation
519 // (it stays on until a product save explicitly clears the toggle). For a
520 // variation, the parent's "any variation enabled" flag is turned on too.
521 // Marker: product has been plan-connected at least once (keeps the editor
522 // in plan mode after a detach).
523 $oid = (int) $params['oid'];
524 if ( ! empty( $params['vid'] ) ) {
525 update_post_meta( (int) $params['vid'], '_subscrpt_enabled', 'yes' );
526 update_post_meta( (int) $params['vid'], '_subscrpt_plan_connected_before', 'yes' );
527 update_post_meta( $oid, '_subscrpt_enabled', 'yes' );
528 update_post_meta( $oid, '_subscrpt_plan_connected_before', 'yes' );
529 } else {
530 update_post_meta( $oid, '_subscrpt_enabled', 'yes' );
531 update_post_meta( $oid, '_subscrpt_plan_connected_before', 'yes' );
532 }
533
534 // Default the purchase limit when the product has never had one set. The
535 // storefront gate (Frontend\Product::check_if_purchasable) only overrides
536 // WooCommerce's empty-price rule when a limit is set, so without this a
537 // plan-connected product with no base price stays un-purchasable — its
538 // plan selector never renders until a product save writes this meta.
539 if ( '' === get_post_meta( $oid, '_subscrpt_limit', true ) ) {
540 update_post_meta( $oid, '_subscrpt_limit', 'unlimited' );
541 }
542
543 return rest_ensure_response( PlanRepository::get_relation( $id ) );
544 }
545
546 /**
547 * PUT /plans/relations/{id} - update a relation (price / exclude).
548 *
549 * @param WP_REST_Request $request Request.
550 *
551 * @return \WP_REST_Response|WP_Error
552 */
553 public function update_relation( WP_REST_Request $request ) {
554 $id = (int) $request['id'];
555
556 if ( ! PlanRepository::get_relation( $id ) ) {
557 return $this->not_found();
558 }
559
560 PlanRepository::update_relation( $id, $this->read_params( $request ) );
561
562 return rest_ensure_response( PlanRepository::get_relation( $id ) );
563 }
564
565 /**
566 * DELETE /plans/relations/{id} - detach a product from a plan term.
567 *
568 * @param WP_REST_Request $request Request.
569 *
570 * @return \WP_REST_Response|WP_Error
571 */
572 public function delete_relation( WP_REST_Request $request ) {
573 $id = (int) $request['id'];
574
575 if ( ! PlanRepository::get_relation( $id ) ) {
576 return $this->not_found();
577 }
578
579 PlanRepository::delete_relation( $id );
580
581 return rest_ensure_response(
582 array(
583 'deleted' => true,
584 'id' => $id,
585 )
586 );
587 }
588
589 /**
590 * PUT /plans/product-onetime/{id} - save a product's one-time purchase.
591 *
592 * One-time purchase is product-specific: its price is the product's native
593 * WooCommerce price (regular = one-time price, sale = one-time offer). A
594 * simple product has a single enabled flag; a variable product enables it
595 * per variation (each variation stores its own flag + native price, and the
596 * parent flag mirrors "any variation enabled").
597 *
598 * Body (simple): { enabled: bool, price?: string, offer?: string }.
599 * Body (variable): { variations: { <vid>: { enabled: bool, price?, offer? } } }.
600 *
601 * @param WP_REST_Request $request Request.
602 *
603 * @return \WP_REST_Response|WP_Error
604 */
605 public function save_product_onetime( WP_REST_Request $request ) {
606 $product_id = (int) $request['id'];
607 $product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null;
608
609 if ( ! $product ) {
610 return $this->not_found();
611 }
612
613 $params = $this->read_params( $request );
614
615 if ( $product->is_type( 'variable' ) ) {
616 // Per-variation one-time: each variation carries its own enabled flag
617 // + native price. Saves may be partial (one variation at a time).
618 $variations = isset( $params['variations'] ) && is_array( $params['variations'] ) ? $params['variations'] : array();
619 foreach ( $variations as $vid => $vals ) {
620 $variation = wc_get_product( (int) $vid );
621 if ( ! $variation || 'variation' !== $variation->get_type() || (int) $variation->get_parent_id() !== $product_id ) {
622 continue;
623 }
624 $variation->update_meta_data( '_subscrpt_one_time_enabled', empty( $vals['enabled'] ) ? '' : 'yes' );
625 $this->set_native_prices( $variation, is_array( $vals ) ? $vals : array() );
626 $variation->save();
627 }
628
629 // Parent flag mirrors "any variation enabled" — recomputed from all
630 // children so a partial save never clears it for other variations.
631 $any_enabled = false;
632 foreach ( $product->get_children() as $child_id ) {
633 if ( 'yes' === get_post_meta( (int) $child_id, '_subscrpt_one_time_enabled', true ) ) {
634 $any_enabled = true;
635 break;
636 }
637 }
638 $product->update_meta_data( '_subscrpt_one_time_enabled', $any_enabled ? 'yes' : '' );
639 } else {
640 $enabled = ! empty( $params['enabled'] );
641 $product->update_meta_data( '_subscrpt_one_time_enabled', $enabled ? 'yes' : '' );
642 $this->set_native_prices( $product, $params );
643 }
644
645 $product->save();
646
647 return rest_ensure_response( array( 'saved' => true ) );
648 }
649
650 /**
651 * Write a product/variation's native regular + sale price from a one-time
652 * price payload ({ price, offer }). Empty values clear the price.
653 *
654 * @param \WC_Product $product Product or variation.
655 * @param array $prices { price, offer } values.
656 *
657 * @return void
658 */
659 protected function set_native_prices( $product, $prices ) {
660 $regular = ( isset( $prices['price'] ) && '' !== $prices['price'] ) ? wc_format_decimal( $prices['price'] ) : '';
661 $offer = ( isset( $prices['offer'] ) && '' !== $prices['offer'] ) ? wc_format_decimal( $prices['offer'] ) : '';
662
663 $product->set_regular_price( $regular );
664 $product->set_sale_price( $offer );
665 $product->set_price( '' !== $offer ? $offer : $regular );
666 }
667
668 /**
669 * GET /plans/product-view/{id} - re-render the product-editor plan view.
670 *
671 * Used to refresh the Subscription tab's plan view in place (no page
672 * reload) after a connect / detach or after creating a plan group + plan
673 * from the product editor, so unsaved product edits are preserved. Returns
674 * only the plan-view fragment; the modals live outside it.
675 *
676 * @param WP_REST_Request $request Request.
677 *
678 * @return \WP_REST_Response|WP_Error
679 */
680 public function product_plan_view( WP_REST_Request $request ) {
681 $product_id = (int) $request->get_param( 'id' );
682 $product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null;
683
684 // render_plan_view() supports simple products (free) and variable products
685 // (Pro reuses it for the variation plan view), so both can refresh in place.
686 if ( ! $product || ! ( $product->is_type( 'simple' ) || $product->is_type( 'variable' ) ) ) {
687 return new WP_Error(
688 'rest_invalid_product',
689 __( 'Plan view is not available for this product type.', 'subscription' ),
690 array( 'status' => 400 )
691 );
692 }
693
694 ob_start();
695 \SpringDevs\Subscription\Admin\Product\Plans::render_plan_view( $product );
696 $html = ob_get_clean();
697
698 return rest_ensure_response( array( 'html' => $html ) );
699 }
700
701 /**
702 * GET /plans/group-products/{id} - re-render a plan's Products tab.
703 *
704 * The same fragment the detail page includes, so attaching, detaching or
705 * repricing can refresh it in place instead of reloading the page. The
706 * alternative is rebuilding PlanPresenter's shape — and WooCommerce's price
707 * formatting — in JavaScript, which would drift from the template the first
708 * time either changed.
709 *
710 * @param WP_REST_Request $request Request.
711 *
712 * @return \WP_REST_Response|WP_Error
713 */
714 public function group_products_view( WP_REST_Request $request ) {
715 $plan = PlanPresenter::group( (int) $request->get_param( 'id' ) );
716
717 if ( empty( $plan ) ) {
718 return $this->not_found();
719 }
720
721 ob_start();
722 require SUBSCRPT_INCLUDES . '/Admin/views/plans/tab-products.php';
723 $html = ob_get_clean();
724
725 return rest_ensure_response( array( 'html' => $html ) );
726 }
727
728 /* ---- Product picker ---- */
729
730 /**
731 * GET /plans/products - search WC products for the connect picker.
732 *
733 * Free is simple-product only: the picker returns simple products.
734 *
735 * @param WP_REST_Request $request Request.
736 *
737 * @return \WP_REST_Response
738 */
739 public function search_products( WP_REST_Request $request ) {
740 $search = sanitize_text_field( (string) $request->get_param( 'search' ) );
741
742 $args = array(
743 'status' => 'publish',
744 'type' => 'simple',
745 'limit' => 20,
746 'return' => 'objects',
747 's' => $search,
748 'orderby' => 'title',
749 'order' => 'ASC',
750 );
751
752 $products = function_exists( 'wc_get_products' ) ? wc_get_products( $args ) : array();
753 $results = array();
754
755 foreach ( $products as $product ) {
756 $results[] = self::product_row( $product );
757 }
758
759 /**
760 * Filter the product-picker results. Free returns simple products only;
761 * Pro hooks this to append variable products (with nested variations).
762 *
763 * @param array $results Product rows (see product_row()).
764 * @param string $search Current search term.
765 */
766 $results = apply_filters( 'subscrpt_plan_products', $results, $search );
767
768 // Newest first — sort parents by ID descending (variations keep their order).
769 usort(
770 $results,
771 function ( $a, $b ) {
772 return (int) $b['id'] - (int) $a['id'];
773 }
774 );
775
776 return rest_ensure_response( $results );
777 }
778
779 /**
780 * Build one product-picker row.
781 *
782 * Shape: id, name, type, image (thumbnail URL or ''), price (numeric,
783 * for the relation price field), price_html (display, decoded), is_virtual,
784 * variations (array of child rows — empty for simple products).
785 *
786 * @param \WC_Product $product Product (or variation).
787 * @param string $name_over Optional name override (used for variations).
788 *
789 * @return array
790 */
791 public static function product_row( $product, $name_over = '' ) {
792 $image_id = $product->get_image_id();
793 $price = (float) wc_get_price_to_display( $product );
794
795 // Amount only — wc_price() skips the subscription "/ period" suffix.
796 // Variable parents have no single price, so they show none.
797 $price_html = $product->is_type( 'variable' )
798 ? ''
799 : html_entity_decode( wp_strip_all_tags( wc_price( $price ) ), ENT_QUOTES, 'UTF-8' );
800
801 // The plan group this product is attached to (0 = none). A product belongs
802 // to a single group; the picker disables rows already in another group.
803 // Variations share their parent's connection (relations use the parent oid),
804 // cached per owner so a product's variations don't each re-query.
805 static $group_cache = array();
806
807 $owner_id = $product->get_parent_id() ? (int) $product->get_parent_id() : (int) $product->get_id();
808 if ( ! isset( $group_cache[ $owner_id ] ) ) {
809 $conns = PlanRepository::get_product_connections( $owner_id );
810 $group_cache[ $owner_id ] = ! empty( $conns )
811 ? array(
812 'id' => (int) $conns[0]['plan_group_id'],
813 'name' => (string) $conns[0]['group_title'],
814 )
815 : array(
816 'id' => 0,
817 'name' => '',
818 );
819 }
820
821 return array(
822 'id' => $product->get_id(),
823 'name' => '' !== $name_over ? $name_over : $product->get_name(),
824 'type' => $product->get_type(),
825 'image' => $image_id ? wp_get_attachment_image_url( $image_id, array( 48, 48 ) ) : '',
826 'price' => $price,
827 'price_html' => $price_html,
828 'is_virtual' => $product->is_virtual(),
829 'plan_group_id' => $group_cache[ $owner_id ]['id'],
830 'plan_group_name' => $group_cache[ $owner_id ]['name'],
831 'variations' => array(),
832 );
833 }
834
835 /* ---- Free constraint guards ---- */
836
837 /**
838 * Reject a non-Recurring group type on a free-only install.
839 *
840 * @param array $params Group params.
841 *
842 * @return true|WP_Error
843 */
844 protected function guard_recurring_only( array $params ) {
845 if ( ! isset( $params['type'] ) || subscrpt_pro_activated() ) {
846 return true;
847 }
848
849 $type_int = is_numeric( $params['type'] ) ? (int) $params['type'] : PlanRepository::type_to_int( $params['type'] );
850
851 if ( PlanRepository::TYPE_MAP['recurring'] !== $type_int ) {
852 return new WP_Error(
853 'subscrpt_plan_type_pro',
854 __( 'Recurring Delivery and Split Payment plans require Subscription Pro.', 'subscription' ),
855 array( 'status' => 403 )
856 );
857 }
858
859 return true;
860 }
861
862 /**
863 * Reject a per-variation relation (vid != 0) on a free-only install.
864 *
865 * @param array $params Relation params.
866 *
867 * @return true|WP_Error
868 */
869 protected function guard_simple_only( array $params ) {
870 if ( subscrpt_pro_activated() ) {
871 return true;
872 }
873
874 if ( ! empty( $params['vid'] ) ) {
875 return new WP_Error(
876 'subscrpt_variation_pro',
877 __( 'Attaching plans to product variations requires Subscription Pro.', 'subscription' ),
878 array( 'status' => 403 )
879 );
880 }
881
882 return true;
883 }
884
885 /**
886 * Standard 404 response.
887 *
888 * @return WP_Error
889 */
890 protected function not_found() {
891 return new WP_Error( 'subscrpt_plan_not_found', __( 'Not found.', 'subscription' ), array( 'status' => 404 ) );
892 }
893 }
894