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

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