PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.2.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.2.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.2.0, at includes/Api/PlanController.php

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