PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.1.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.1.0
2.4.0 2.3.0 2.2.0 2.1.0 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 All 65 releases
subscription / includes / Api / PlanController.php

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

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