PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / api / cart.php

cart.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/api/cart.php

581 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cart REST API
4 *
5 * Public, headless-friendly REST controller for the cart. Mirrors the
6 * existing admin-ajax cart actions (`add_to_cart`, `update_cart_item_quantity`,
7 * `remove_cart_item`, `clear_cart`, `direct_checkout`) 1:1 in functionality,
8 * but speaks JSON over REST and uses the same dual-auth model as the
9 * checkout REST surface:
10 *
11 * - Cookie auth + `X-WP-Nonce` for same-site / WP-rendered storefronts.
12 * - Publishable-key + Origin allow-list (handled by the Instant Checkout
13 * addon's `pk_auth` middleware via the
14 * `storeengine/checkout/publishable_key_auth` filter) for cross-origin /
15 * fully headless storefronts.
16 *
17 * Routes:
18 * GET /wp-json/storeengine/v1/cart — full snapshot
19 * DELETE /wp-json/storeengine/v1/cart — clear the cart
20 * POST /wp-json/storeengine/v1/cart/items — add an item
21 * POST /wp-json/storeengine/v1/cart/items/direct-checkout — clear + add (Buy Now)
22 * PATCH /wp-json/storeengine/v1/cart/items/<key> — update quantity
23 * DELETE /wp-json/storeengine/v1/cart/items/<key> — remove an item
24 * DELETE /wp-json/storeengine/v1/cart/items — clear the cart (alias)
25 * GET /wp-json/storeengine/v1/cart/refresh-fragments — re-rendered cart shortcode HTML
26 * GET /wp-json/storeengine/v1/cart/products/<id>/variations — variation lookup
27 */
28
29 namespace StoreEngine\API;
30
31 use StoreEngine;
32 use StoreEngine\Classes\Price;
33 use StoreEngine\Utils\Formatting;
34 use StoreEngine\Utils\Helper;
35 use WP_Error;
36 use WP_REST_Request;
37 use WP_REST_Server;
38
39 if ( ! defined( 'ABSPATH' ) ) {
40 exit;
41 }
42
43 class Cart extends AbstractRestApiController {
44
45 protected $rest_base = 'cart';
46
47 public static function init() {
48 $self = new self();
49 add_action( 'rest_api_init', [ $self, 'register_routes' ] );
50 }
51
52 public function register_routes() {
53 register_rest_route( $this->namespace, '/' . $this->rest_base, [
54 [
55 'methods' => WP_REST_Server::READABLE,
56 'callback' => [ $this, 'get_cart' ],
57 'permission_callback' => [ $this, 'permission_callback' ],
58 ],
59 [
60 'methods' => WP_REST_Server::DELETABLE,
61 'callback' => [ $this, 'clear_cart' ],
62 'permission_callback' => [ $this, 'permission_callback' ],
63 ],
64 ] );
65
66 register_rest_route( $this->namespace, '/' . $this->rest_base . '/items', [
67 [
68 'methods' => WP_REST_Server::CREATABLE,
69 'callback' => [ $this, 'add_item' ],
70 'permission_callback' => [ $this, 'permission_callback' ],
71 'args' => $this->add_item_args(),
72 ],
73 [
74 'methods' => WP_REST_Server::DELETABLE,
75 'callback' => [ $this, 'clear_cart' ],
76 'permission_callback' => [ $this, 'permission_callback' ],
77 ],
78 ] );
79
80 register_rest_route( $this->namespace, '/' . $this->rest_base . '/items/direct-checkout', [
81 [
82 'methods' => WP_REST_Server::CREATABLE,
83 'callback' => [ $this, 'direct_checkout' ],
84 'permission_callback' => [ $this, 'permission_callback' ],
85 'args' => $this->add_item_args(),
86 ],
87 ] );
88
89 register_rest_route( $this->namespace, '/' . $this->rest_base . '/items/(?P<key>[A-Za-z0-9]+)', [
90 'args' => [
91 'key' => [ 'type' => 'string', 'required' => true ],
92 ],
93 [
94 'methods' => 'PATCH',
95 'callback' => [ $this, 'update_cart_item' ],
96 'permission_callback' => [ $this, 'permission_callback' ],
97 'args' => [
98 'quantity' => [ 'type' => 'integer', 'required' => true, 'minimum' => 0 ],
99 ],
100 ],
101 [
102 'methods' => WP_REST_Server::DELETABLE,
103 'callback' => [ $this, 'remove_cart_item' ],
104 'permission_callback' => [ $this, 'permission_callback' ],
105 ],
106 ] );
107
108 register_rest_route( $this->namespace, '/' . $this->rest_base . '/refresh-fragments', [
109 [
110 'methods' => WP_REST_Server::READABLE,
111 'callback' => [ $this, 'refresh_fragments' ],
112 'permission_callback' => [ $this, 'permission_callback' ],
113 ],
114 ] );
115
116 register_rest_route( $this->namespace, '/' . $this->rest_base . '/products/(?P<product_id>\d+)/variations', [
117 [
118 'methods' => WP_REST_Server::READABLE,
119 'callback' => [ $this, 'get_variations' ],
120 'permission_callback' => [ $this, 'permission_callback' ],
121 'args' => [
122 'product_id' => [ 'type' => 'integer', 'required' => true ],
123 ],
124 ],
125 ] );
126 }
127
128 protected function add_item_args(): array {
129 return [
130 'price_id' => [ 'type' => 'integer', 'required' => true, 'minimum' => 1 ],
131 'product_id' => [ 'type' => 'integer', 'required' => false ],
132 'variation_id' => [ 'type' => 'integer', 'required' => false ],
133 'quantity' => [ 'type' => 'integer', 'required' => false, 'minimum' => 1, 'default' => 1 ],
134 ];
135 }
136
137 /**
138 * Dual-auth permission check — mirrors API\Checkout::permission_callback().
139 *
140 * - X-StoreEngine-Pk header: defer to the addon's
141 * `storeengine/checkout/publishable_key_auth` filter.
142 * - Otherwise: same-site cookie auth (WP REST nonce already validated).
143 */
144 public function permission_callback( WP_REST_Request $request ) {
145 $pk = $request->get_header( 'x_storeengine_pk' );
146 if ( ! $pk ) {
147 $pk = $request->get_param( 'pk' );
148 }
149
150 if ( $pk ) {
151 $result = apply_filters( 'storeengine/checkout/publishable_key_auth', null, $request );
152 if ( null === $result ) {
153 return new WP_Error(
154 'storeengine_cart_pk_unsupported',
155 __( 'Publishable-key authentication is not active. Enable the Instant Checkout addon.', 'storeengine' ),
156 [ 'status' => 401 ]
157 );
158 }
159 return $result;
160 }
161
162 // Allow guests on cart endpoints — same as the legacy admin-ajax handler.
163 return true;
164 }
165
166 /* ── Endpoints ──────────────────────────────────────────────────────── */
167
168 public function get_cart() {
169 StoreEngine::init()->load_cart();
170 return rest_ensure_response( $this->snapshot() );
171 }
172
173 public function add_item( WP_REST_Request $request ) {
174 StoreEngine::init()->load_cart();
175 $prepared = $this->prepare_item_payload( $request );
176 if ( is_wp_error( $prepared ) ) {
177 return $prepared;
178 }
179
180 $result = Helper::cart()->add_product_to_cart(
181 $prepared['price_id'],
182 $prepared['quantity'],
183 $prepared['variation_id'],
184 $prepared['variation_data']
185 );
186
187 if ( is_wp_error( $result ) ) {
188 return new WP_Error( $result->get_error_code(), $result->get_error_message(), [ 'status' => 422 ] );
189 }
190
191 // Both hook names are fired so addons that listened to either continue
192 // to work after the admin-ajax → REST migration. Same payload shape.
193 do_action( 'storeengine/cart/added_to_cart', $request->get_params() );
194 do_action( 'storeengine/ajax/cart/added_to_cart', $request->get_params() );
195
196 return rest_ensure_response( $this->build_add_to_cart_response( $prepared['product_id'], $prepared['price_id'], false ) );
197 }
198
199 public function direct_checkout( WP_REST_Request $request ) {
200 StoreEngine::init()->load_cart();
201 $prepared = $this->prepare_item_payload( $request );
202 if ( is_wp_error( $prepared ) ) {
203 return $prepared;
204 }
205
206 Helper::cart()->clear_cart();
207
208 $result = Helper::cart()->add_product_to_cart(
209 $prepared['price_id'],
210 $prepared['quantity'],
211 $prepared['variation_id'],
212 $prepared['variation_data']
213 );
214
215 if ( is_wp_error( $result ) ) {
216 return new WP_Error( $result->get_error_code(), $result->get_error_message(), [ 'status' => 422 ] );
217 }
218
219 do_action( 'storeengine/cart/added_to_cart', $request->get_params() );
220 do_action( 'storeengine/ajax/cart/added_to_cart', $request->get_params() );
221
222 return rest_ensure_response( $this->build_add_to_cart_response( $prepared['product_id'], $prepared['price_id'], true ) );
223 }
224
225 public function update_cart_item( WP_REST_Request $request ) {
226 StoreEngine::init()->load_cart();
227 $key = sanitize_text_field( (string) $request->get_param( 'key' ) );
228 $quantity = (int) $request->get_param( 'quantity' );
229
230 $result = Helper::cart()->update_quantity( $key, $quantity );
231 if ( is_wp_error( $result ) ) {
232 return new WP_Error( $result->get_error_code(), $result->get_error_message(), [ 'status' => 422 ] );
233 }
234
235 $item = Helper::cart()->get_cart_item( $key );
236 if ( ! $item ) {
237 // Quantity 0 removes the item — no item left to return.
238 return rest_ensure_response( [
239 'item' => null,
240 'product_id' => 0,
241 'price_id' => 0,
242 'message' => __( 'Item updated in your cart.', 'storeengine' ),
243 'snapshot' => $this->snapshot(),
244 ] );
245 }
246
247 $product = Helper::get_product( $item->product_id );
248 $title = $product ? $product->get_name() : ( $item->name ?? '' );
249 /* translators: %s: Item name in quotes. */
250 $title = $title ? sprintf( _x( '&ldquo;%s&rdquo;', 'Item name in quotes', 'storeengine' ), $title ) : __( 'Item', 'storeengine' );
251 $message = sprintf(
252 /* translators: %s Product title. */
253 __( '%s updated in your cart.', 'storeengine' ),
254 apply_filters( 'storeengine/cart/item_updated_title', $title, $item )
255 );
256
257 return rest_ensure_response( [
258 'item' => $item,
259 'product_id' => (int) $item->product_id,
260 'price_id' => (int) ( $item->price_id ?? 0 ),
261 'message' => $message,
262 'snapshot' => $this->snapshot(),
263 ] );
264 }
265
266 public function remove_cart_item( WP_REST_Request $request ) {
267 StoreEngine::init()->load_cart();
268 $key = sanitize_text_field( (string) $request->get_param( 'key' ) );
269
270 $item = Helper::cart()->get_cart_item( $key );
271 if ( ! $item ) {
272 return new WP_Error( 'storeengine_cart_item_not_found', __( 'Cart item not found.', 'storeengine' ), [ 'status' => 404 ] );
273 }
274
275 $product = Helper::get_product( $item->product_id );
276 $title = $product ? $product->get_name() : ( $item->name ?? '' );
277 /* translators: %s: Item name in quotes. */
278 $title = $title ? sprintf( _x( '&ldquo;%s&rdquo;', 'Item name in quotes', 'storeengine' ), $title ) : __( 'Item', 'storeengine' );
279 $message = sprintf(
280 /* translators: %s Product title. */
281 __( '%s removed from your cart.', 'storeengine' ),
282 apply_filters( 'storeengine/cart/item_removed_title', $title, $item )
283 );
284
285 if ( ! Helper::cart()->remove_cart_item( $key ) ) {
286 return new WP_Error( 'storeengine_cart_remove_failed', __( 'Failed to remove item from cart.', 'storeengine' ), [ 'status' => 500 ] );
287 }
288
289 return rest_ensure_response( [
290 'removed' => true,
291 'item_key' => $key,
292 'item' => $item,
293 'product_id' => (int) $item->product_id,
294 'price_id' => (int) ( $item->price_id ?? 0 ),
295 'message' => $message,
296 'snapshot' => $this->snapshot(),
297 ] );
298 }
299
300 /**
301 * Build the add-to-cart response shape consumed by the storefront cart.js.
302 * Mirrors what the legacy admin-ajax handler returned (item + product_id +
303 * price_id + optional redirect) and runs the same legacy filter so addons
304 * that hooked into `storeengine/ajax/cart/add_to_cart_response` still work.
305 */
306 protected function build_add_to_cart_response( int $product_id, int $price_id, bool $redirect ): array {
307 $item = Helper::cart()->get_cart_item_by_product( $product_id, $price_id );
308 $data = [
309 'item' => $item,
310 'product_id' => $product_id,
311 'price_id' => $price_id,
312 'snapshot' => $this->snapshot(),
313 ];
314 if ( $redirect ) {
315 $data['redirect'] = esc_url_raw( Helper::get_checkout_url() );
316 }
317
318 return apply_filters( 'storeengine/ajax/cart/add_to_cart_response', $data, $product_id, $price_id );
319 }
320
321 /**
322 * Replacement for the legacy `refresh_cart` admin-ajax action.
323 * Returns the same set of HTML fragments cart.js swaps in after each cart
324 * mutation: cart sub-total table, order summary, and the checkout
325 * payment-method block (so available gateways re-render after totals shift).
326 */
327 public function refresh_fragments() {
328 StoreEngine::init()->load_cart();
329
330 /*
331 * The cart sub-total fragment is shared by the cart and checkout pages,
332 * but the shipping template (cart/cart-shipping.php) renders differently
333 * for each: a compact "chosen method" line on the cart vs a selectable
334 * radio list on checkout. This REST refresh carries no page context, so
335 * the shared STOREENGINE_CART flag used to be defined unconditionally —
336 * which made Helper::is_cart() true even for checkout refreshes and
337 * collapsed the shipping selector into a single hidden radio. Detect the
338 * originating page from the referer and flag the correct context instead.
339 */
340 if ( $this->is_cart_page_request() ) {
341 if ( ! defined( 'STOREENGINE_CART' ) ) {
342 define( 'STOREENGINE_CART', true );
343 }
344 } elseif ( ! defined( 'STOREENGINE_CHECKOUT' ) ) {
345 define( 'STOREENGINE_CHECKOUT', true );
346 }
347 set_query_var( 'order_pay', 'false' );
348
349 $shortcodes = apply_filters( 'storeengine/cart/refresh_shortcodes', [
350 'storeengine-order-summary-shortcode' => '[storeengine_order_summary]',
351 'storeengine-cart-sub-total-table-shortcode' => '[storeengine_cart_sub_total_table]',
352 ] );
353
354 $fragments = [];
355 foreach ( $shortcodes as $class => $shortcode ) {
356 $fragments[ $class ] = do_shortcode( $shortcode );
357 }
358
359 ob_start();
360 if ( function_exists( 'storeengine_checkout_payment_method' ) ) {
361 storeengine_checkout_payment_method();
362 }
363 $fragments['storeengine-ajax-checkout-form__payments'] = ob_get_clean();
364
365 return rest_ensure_response( $fragments );
366 }
367
368 /**
369 * Whether the current fragment-refresh request originated from the cart page.
370 *
371 * Keeps Helper::is_cart()/is_checkout() accurate during the context-less REST
372 * refresh so shared fragments (notably the shipping method selector) render in
373 * the right mode. Defaults to checkout context when the referer is missing or
374 * unresolvable, because that is the page that needs the selectable shipping UI.
375 *
376 * @return bool
377 */
378 protected function is_cart_page_request(): bool {
379 $referer = wp_get_referer();
380 if ( ! $referer ) {
381 return false;
382 }
383
384 $cart_page_id = absint( Helper::get_settings( 'cart_page' ) );
385 if ( ! $cart_page_id ) {
386 return false;
387 }
388
389 return absint( url_to_postid( $referer ) ) === $cart_page_id;
390 }
391
392 public function clear_cart() {
393 StoreEngine::init()->load_cart();
394 Helper::cart()->clear_cart();
395
396 return rest_ensure_response( [
397 'cleared' => true,
398 'snapshot' => $this->snapshot(),
399 ] );
400 }
401
402 public function get_variations( WP_REST_Request $request ) {
403 $product_id = (int) $request->get_param( 'product_id' );
404 $product = Helper::get_product( $product_id );
405 if ( ! $product ) {
406 return new WP_Error( 'storeengine_product_not_found', __( 'Product not found.', 'storeengine' ), [ 'status' => 404 ] );
407 }
408 if ( ! $product->is_type( 'variable' ) ) {
409 return new WP_Error( 'storeengine_product_not_variable', __( 'Product is not variable.', 'storeengine' ), [ 'status' => 422 ] );
410 }
411
412 $variations = method_exists( $product, 'get_variations' ) ? $product->get_variations() : [];
413
414 return rest_ensure_response( [
415 'product_id' => $product_id,
416 'variations' => $variations,
417 ] );
418 }
419
420 /* ── Helpers ────────────────────────────────────────────────────────── */
421
422 /**
423 * Validate + normalise an add-to-cart payload — same logic the legacy
424 * AJAX handler uses, just returning WP_Error instead of wp_send_json_error.
425 *
426 * @return array{price_id:int,product_id:int,quantity:int,variation_id:int,variation_data:array}|WP_Error
427 */
428 protected function prepare_item_payload( WP_REST_Request $request ) {
429 $price_id = (int) $request->get_param( 'price_id' );
430 if ( ! $price_id ) {
431 return new WP_Error( 'storeengine_cart_invalid_price', __( 'Price ID is required.', 'storeengine' ), [ 'status' => 422 ] );
432 }
433
434 try {
435 $price = new Price( $price_id );
436 } catch ( \Throwable $e ) {
437 return new WP_Error( 'storeengine_cart_invalid_price', __( 'Invalid price ID.', 'storeengine' ), [ 'status' => 422 ] );
438 }
439
440 $product = $price->get_product();
441 if ( ! $product ) {
442 return new WP_Error( 'storeengine_cart_product_not_found', __( 'Product not found.', 'storeengine' ), [ 'status' => 404 ] );
443 }
444
445 $quantity = max( 1, (int) ( $request->get_param( 'quantity' ) ?: 1 ) );
446
447 $out = [
448 'price_id' => $price->get_id(),
449 'product_id' => $product->get_id(),
450 'quantity' => $quantity,
451 'variation_id' => 0,
452 'variation_data' => [],
453 ];
454
455 if ( $product->is_type( 'variable' ) ) {
456 $variation_id = (int) $request->get_param( 'variation_id' );
457 if ( ! $variation_id ) {
458 return new WP_Error( 'storeengine_cart_variation_required', __( 'Variation ID is required.', 'storeengine' ), [ 'status' => 422 ] );
459 }
460 $variation = Helper::get_product_variation( $variation_id );
461 if ( ! $variation ) {
462 return new WP_Error( 'storeengine_cart_invalid_variation', __( 'Invalid variation ID.', 'storeengine' ), [ 'status' => 422 ] );
463 }
464 $out['variation_id'] = $variation->get_id();
465 foreach ( $variation->get_attributes() as $attr ) {
466 if ( isset( $attr->taxonomy, $attr->slug ) ) {
467 $out['variation_data'][ $attr->taxonomy ] = $attr->slug;
468 }
469 }
470 }
471
472 return $out;
473 }
474
475 /**
476 * A consistent cart snapshot returned alongside every mutation. Matches the
477 * shape of the cart-relevant subset of the checkout snapshot so headless
478 * storefronts can reuse the same UI helpers.
479 */
480 protected function snapshot(): array {
481 $cart = Helper::cart();
482 if ( ! $cart ) {
483 return [
484 'items' => [],
485 'totals' => [ 'subtotal' => 0, 'shipping' => 0, 'tax' => 0, 'discount' => 0, 'total' => 0 ],
486 'currency' => Formatting::get_currency(),
487 'needs_shipping' => false,
488 'needs_payment' => false,
489 'applied_coupons' => [],
490 'item_count' => 0,
491 ];
492 }
493
494 $cart->calculate_totals();
495
496 $per_item_discounts = method_exists( $cart, 'get_coupon_discount_per_item' ) ? $cart->get_coupon_discount_per_item() : [];
497 $reward_units = method_exists( $cart, 'get_reward_units' ) ? $cart->get_reward_units() : [];
498
499 $items = [];
500 foreach ( $cart->get_cart_items() as $cart_item_key => $item ) {
501 $product = Helper::get_product( $item->product_id );
502
503 // Rewarded ("free") units + per-item coupon discount, so the UI can
504 // mark or split free lines.
505 $free_units = 0;
506 $free_coupon = '';
507 foreach ( $reward_units as $code => $keys ) {
508 if ( ! empty( $keys[ $cart_item_key ] ) ) {
509 $free_units += (int) $keys[ $cart_item_key ];
510 $free_coupon = (string) $code;
511 }
512 }
513
514 $items[] = [
515 'item_key' => $cart_item_key,
516 'product_id' => (int) $item->product_id,
517 'price_id' => (int) ( $item->price_id ?? 0 ),
518 'name' => $product ? $product->get_name() : ( $item->name ?? '' ),
519 'image' => $product ? ( get_the_post_thumbnail_url( $item->product_id, 'thumbnail' ) ?: null ) : null,
520 'quantity' => (int) $item->quantity,
521 'price' => (float) ( $item->price ?? 0 ),
522 'subtotal' => (float) ( $item->subtotal ?? ( ( $item->price ?? 0 ) * ( $item->quantity ?? 0 ) ) ),
523 'variation_id' => (int) ( $item->variation_id ?? 0 ),
524 'coupon_discount' => (float) array_sum( $per_item_discounts[ $cart_item_key ] ?? [] ),
525 'free_units' => $free_units,
526 'free_units_coupon' => $free_coupon,
527 // Add-on line markers (e.g. an auto-added gift line).
528 'item_data' => (array) ( $item->item_data ?? [] ),
529 ];
530 }
531
532 $applied_coupons = [];
533 if ( method_exists( $cart, 'get_coupons' ) ) {
534 foreach ( $cart->get_coupons() as $coupon ) {
535 if ( ! is_object( $coupon ) ) {
536 continue;
537 }
538 $code = method_exists( $coupon, 'get_code' )
539 ? (string) $coupon->get_code()
540 : ( property_exists( $coupon, 'code' ) ? (string) $coupon->code : '' );
541 if ( '' === $code ) {
542 continue;
543 }
544 $applied_coupons[] = [
545 'code' => $code,
546 'discount' => method_exists( $cart, 'get_coupon_discount_amount' )
547 ? (float) $cart->get_coupon_discount_amount( $code )
548 : 0.0,
549 ];
550 }
551 }
552
553 $snapshot = [
554 'items' => $items,
555 'totals' => [
556 'subtotal' => (float) $cart->get_cart_subtotal(),
557 'shipping' => (float) $cart->get_shipping_total(),
558 'tax' => (float) $cart->get_taxes_total( true, false ),
559 'discount' => (float) $cart->get_discount_total(),
560 'total' => (float) $cart->get_total( 'edit' ),
561 ],
562 'currency' => Formatting::get_currency(),
563 'needs_shipping' => $cart->needs_shipping(),
564 'needs_payment' => $cart->needs_payment(),
565 'applied_coupons' => $applied_coupons,
566 'item_count' => array_sum( array_map( fn( $i ) => $i['quantity'], $items ) ),
567 ];
568
569 /**
570 * Filters the cart snapshot returned to storefront clients.
571 *
572 * Lets addons enrich the snapshot, e.g. split rewarded units into a
573 * separate "FREE" line or inject coupon-goal suggestions.
574 *
575 * @param array $snapshot The cart snapshot.
576 * @param Cart $cart The cart object.
577 */
578 return apply_filters( 'storeengine/cart/snapshot', $snapshot, $cart );
579 }
580 }
581