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 / checkout.php

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

1,271 lines 48.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Core Checkout REST controller.
4 *
5 * Single REST surface used by:
6 * 1. The traditional /checkout/ page (cookie-authed via X-WP-Nonce).
7 * 2. The Embedded Checkout React app (cross-origin via publishable key + origin allow-list).
8 *
9 * The auth strategy is selected per-request: if the X-StoreEngine-Pk header is
10 * present we delegate the permission check to the Embedded Checkout addon's
11 * publishable-key middleware (via filter); otherwise we fall back to the
12 * standard WordPress cookie/nonce check. This keeps the cross-origin path
13 * intact while making the same routes consumable by the same-site vanilla-JS client.
14 */
15
16 namespace StoreEngine\API;
17
18 use StoreEngine;
19 use StoreEngine\Classes\CheckoutService;
20 use StoreEngine\Classes\Countries;
21 use StoreEngine\Classes\Exceptions\StoreEngineException;
22 use StoreEngine\Classes\Order;
23 use StoreEngine\Utils\CheckoutFields;
24 use StoreEngine\Utils\Formatting;
25 use StoreEngine\Utils\Helper;
26 use StoreEngine\Utils\PaymentUtil;
27 use WP_Error;
28 use WP_REST_Request;
29 use WP_REST_Response;
30 use WP_REST_Server;
31
32 if ( ! defined( 'ABSPATH' ) ) {
33 exit;
34 }
35
36 class Checkout extends AbstractRestApiController {
37
38 protected $rest_base = 'checkout';
39
40 public static function init() {
41 $self = new self();
42 add_action( 'rest_api_init', [ $self, 'register_routes' ] );
43 add_filter( 'rest_pre_serve_request', [ $self, 'maybe_send_cors_headers' ], 10, 4 );
44 }
45
46 public function register_routes() {
47 $args_session = [
48 'session_id' => [ 'type' => 'string', 'required' => false ],
49 ];
50
51 register_rest_route( $this->namespace, '/' . $this->rest_base . '/state', [
52 [
53 'methods' => WP_REST_Server::READABLE,
54 'callback' => [ $this, 'get_state' ],
55 'permission_callback' => [ $this, 'permission_callback' ],
56 'args' => $args_session,
57 ],
58 ] );
59
60 register_rest_route( $this->namespace, '/' . $this->rest_base . '/update', [
61 [
62 'methods' => WP_REST_Server::CREATABLE,
63 'callback' => [ $this, 'update_checkout' ],
64 'permission_callback' => [ $this, 'permission_callback' ],
65 'args' => array_merge( $args_session, [
66 'fields' => [ 'type' => 'object', 'required' => true ],
67 ] ),
68 ],
69 ] );
70
71 register_rest_route( $this->namespace, '/' . $this->rest_base . '/place', [
72 [
73 'methods' => WP_REST_Server::CREATABLE,
74 'callback' => [ $this, 'place_order' ],
75 'permission_callback' => [ $this, 'permission_callback' ],
76 'args' => array_merge( $args_session, [
77 'fields' => [ 'type' => 'object', 'required' => true ],
78 'payment_method' => [ 'type' => 'string', 'required' => true ],
79 'payment_payload' => [ 'type' => 'object', 'required' => false ],
80 ] ),
81 ],
82 ] );
83
84 register_rest_route( $this->namespace, '/' . $this->rest_base . '/states', [
85 [
86 'methods' => WP_REST_Server::READABLE,
87 'callback' => [ $this, 'get_states' ],
88 'permission_callback' => [ $this, 'permission_callback' ],
89 'args' => [
90 'country_code' => [ 'type' => 'string', 'required' => true ],
91 ],
92 ],
93 ] );
94
95 register_rest_route( $this->namespace, '/' . $this->rest_base . '/pay-order', [
96 [
97 'methods' => WP_REST_Server::CREATABLE,
98 'callback' => [ $this, 'pay_order' ],
99 'permission_callback' => [ $this, 'permission_callback' ],
100 'args' => [
101 'order_id' => [ 'type' => 'integer', 'required' => true ],
102 'order_key' => [ 'type' => 'string', 'required' => false ],
103 'payment_method' => [ 'type' => 'string', 'required' => true ],
104 'payment_payload' => [ 'type' => 'object', 'required' => false ],
105 ],
106 ],
107 ] );
108
109 register_rest_route( $this->namespace, '/' . $this->rest_base . '/payment-intent/(?P<gateway_id>[a-zA-Z0-9_-]+)', [
110 [
111 'methods' => WP_REST_Server::CREATABLE,
112 'callback' => [ $this, 'create_payment_intent' ],
113 'permission_callback' => [ $this, 'permission_callback' ],
114 'args' => array_merge( $args_session, [
115 'gateway_id' => [ 'type' => 'string', 'required' => true ],
116 'order_id' => [ 'type' => 'integer', 'required' => false ],
117 ] ),
118 ],
119 ] );
120
121 register_rest_route( $this->namespace, '/' . $this->rest_base . '/coupon/apply', [
122 [
123 'methods' => WP_REST_Server::CREATABLE,
124 'callback' => [ $this, 'apply_coupon' ],
125 'permission_callback' => [ $this, 'permission_callback' ],
126 'args' => array_merge( $args_session, [
127 'coupon_code' => [ 'type' => 'string', 'required' => true ],
128 ] ),
129 ],
130 ] );
131
132 register_rest_route( $this->namespace, '/' . $this->rest_base . '/coupon/remove', [
133 [
134 'methods' => WP_REST_Server::CREATABLE,
135 'callback' => [ $this, 'remove_coupon' ],
136 'permission_callback' => [ $this, 'permission_callback' ],
137 'args' => array_merge( $args_session, [
138 'coupon_code' => [ 'type' => 'string', 'required' => true ],
139 ] ),
140 ],
141 ] );
142
143 register_rest_route( $this->namespace, '/' . $this->rest_base . '/check-contact', [
144 [
145 'methods' => WP_REST_Server::CREATABLE,
146 'callback' => [ $this, 'check_contact' ],
147 'permission_callback' => [ $this, 'permission_callback' ],
148 'args' => [
149 'email' => [ 'type' => 'string', 'required' => true ],
150 ],
151 ],
152 ] );
153 }
154
155 /**
156 * Dual-auth permission check.
157 *
158 * - If `X-StoreEngine-Embed-Key` (or legacy `X-StoreEngine-Pk`) header is
159 * present: the request originates from a cross-origin embed. We let the
160 * embed-key middleware (registered by the Embeddable Checkout addon via
161 * the `storeengine/checkout/publishable_key_auth` filter) decide.
162 * - Otherwise: same-site request. We require the standard WP REST nonce.
163 */
164 public function permission_callback( WP_REST_Request $request ) {
165 $pk = $request->get_header( 'x_storeengine_embed_key' );
166 if ( ! $pk ) {
167 $pk = $request->get_header( 'x_storeengine_pk' ); // legacy
168 }
169 if ( ! $pk ) {
170 $pk = $request->get_param( 'pk' );
171 }
172
173 if ( $pk ) {
174 $result = apply_filters( 'storeengine/checkout/publishable_key_auth', null, $request );
175 // Filter returns true (allow), WP_Error (deny), or null (no handler installed).
176 if ( null === $result ) {
177 return new WP_Error(
178 'storeengine_checkout_pk_unsupported',
179 __( 'Embed-key authentication is not active. Enable the Embeddable Checkout addon.', 'storeengine' ),
180 [ 'status' => 401 ]
181 );
182 }
183 return $result;
184 }
185
186 // Same-site path: require an authenticated REST request (nonce already validated by WP).
187 // Allow guests on the public checkout flow — same as the legacy admin-ajax handler.
188 return true;
189 }
190
191 /**
192 * Send CORS headers for our own routes only when the publishable-key auth path is in use.
193 */
194 public function maybe_send_cors_headers( $served, $result, $request, $server ) {
195 if ( ! ( $request instanceof WP_REST_Request ) ) {
196 return $served;
197 }
198
199 $route = $request->get_route();
200 if ( strpos( $route, '/' . $this->namespace . '/' . $this->rest_base ) !== 0 ) {
201 return $served;
202 }
203
204 // Only the addon middleware sets this, and only on success.
205 $origin = apply_filters( 'storeengine/checkout/cors_origin', null, $request );
206 if ( $origin ) {
207 header( 'Access-Control-Allow-Origin: ' . $origin );
208 header( 'Vary: Origin' );
209 header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
210 header( 'Access-Control-Allow-Headers: Content-Type, X-StoreEngine-Embed-Key, X-StoreEngine-Pk, X-WP-Nonce' );
211 header( 'Access-Control-Allow-Credentials: false' );
212 }
213
214 return $served;
215 }
216
217 // ---- Routes ------------------------------------------------------------
218
219 public function get_state( WP_REST_Request $request ) {
220 $prep = $this->prepare_request( $request );
221 if ( is_wp_error( $prep ) ) {
222 return $prep;
223 }
224
225 // Wrap the snapshot build so any exception (e.g. a null-date ->format(),
226 // a null price object, or a hook throwing on a licensed/deployment
227 // product) surfaces as a clean 500 JSON error instead of a fatal that
228 // truncates an already-committed 200 response — which the embedded React
229 // checkout would otherwise read as `null` and crash on with a cryptic
230 // "Cannot read properties of null (reading 'declared_items')".
231 try {
232 return rest_ensure_response( $this->snapshot( $prep['session'] ) );
233 } catch ( \Throwable $e ) {
234 return new WP_Error(
235 'storeengine_checkout_state_failed',
236 $e->getMessage(),
237 [ 'status' => 500 ]
238 );
239 }
240 }
241
242 /**
243 * "Does an account already use this email?" probe for the checkout contact
244 * field. Lets the UI warn the shopper — with a login link — the moment they
245 * enter an email that belongs to an existing account, instead of only
246 * failing at Place Order (create_customer() refuses to let a guest attach an
247 * order to someone else's account). Returns { exists, login_url }.
248 *
249 * The login_url is only populated when flagging a *different* account: a
250 * logged-in shopper entering their own email needs no login prompt (that
251 * request falls through to the normal update path server-side).
252 */
253 public function check_contact( WP_REST_Request $request ) {
254 $email = sanitize_email( (string) $request->get_param( 'email' ) );
255
256 // Never confirm existence for a malformed address. Keeps this endpoint
257 // from acting as a bulk validity oracle and mirrors the client, which
258 // only calls it once the address is well-formed.
259 if ( ! $email || ! is_email( $email ) ) {
260 return rest_ensure_response( [
261 'exists' => false,
262 'login_url' => '',
263 ] );
264 }
265
266 $user_id = email_exists( $email );
267 $exists = $user_id && get_current_user_id() !== (int) $user_id;
268
269 return rest_ensure_response( [
270 'exists' => (bool) $exists,
271 'login_url' => $exists ? storeengine_checkout_login_url( $email ) : '',
272 ] );
273 }
274
275 public function update_checkout( WP_REST_Request $request ) {
276 $prep = $this->prepare_request( $request );
277 if ( is_wp_error( $prep ) ) {
278 return $prep;
279 }
280
281 $fields = (array) $request->get_param( 'fields' );
282
283 // Fingerprint the totals BEFORE applying the change (the cart was already
284 // calculated at request bootstrap). Compared after the recalc below so
285 // ANY field that moves a total — shipping, tax, fees, addon-driven
286 // surcharges, not just the address — flips `refresh` and re-renders the
287 // summary. Without this, only address/shipping-method edits refreshed.
288 $old_totals = self::totals_fingerprint( Helper::cart() );
289
290 // Push the submitted address + shipping choice onto the cart and
291 // recalculate, so shipping/tax reflect the current location.
292 self::sync_cart_from_fields( $fields );
293
294 $new_totals = self::totals_fingerprint( Helper::cart() );
295
296 // CheckoutService::update_checkout returns the legacy "refresh" payload
297 // shape (refresh, refresh_payment_methods, hash, …) that
298 // CheckoutManager.js already understands.
299 // Wrap in try/catch so any per-field validation exception thrown by
300 // a custom hook surfaces as a clean 422 instead of an unhandled
301 // PHP fatal — this endpoint runs on every debounced keystroke from
302 // the React Quick Checkout while the shopper is typing.
303 try {
304 $response = CheckoutService::update_checkout( $fields );
305 } catch ( \StoreEngine\Classes\Exceptions\StoreEngineException $e ) {
306 return new WP_Error(
307 $e->get_wp_error_code() ?: 'storeengine_checkout_update_failed',
308 $e->getMessage(),
309 [ 'status' => 422 ]
310 );
311 } catch ( \Throwable $e ) {
312 return new WP_Error(
313 'storeengine_checkout_update_failed',
314 $e->getMessage(),
315 [ 'status' => 500 ]
316 );
317 }
318
319 // Re-render the summary whenever the totals moved, regardless of which
320 // field caused it (belt-and-suspenders over the address diff inside
321 // CheckoutService::update_checkout).
322 if ( $old_totals !== $new_totals ) {
323 $response['refresh'] = true;
324 }
325
326 // Same protection as get_state(): never let a snapshot-build failure
327 // truncate the /update response (which carries the refresh payload the
328 // checkout depends on). If it fails, omit the snapshot and skip the
329 // re-render rather than 500-ing the whole update.
330 try {
331 $response['snapshot'] = $this->snapshot( $prep['session'] );
332 } catch ( \Throwable $e ) {
333 unset( $response['snapshot'] );
334 $response['refresh'] = false;
335 }
336
337 return rest_ensure_response( $response );
338 }
339
340 public function place_order( WP_REST_Request $request ) {
341 $prep = $this->prepare_request( $request );
342 if ( is_wp_error( $prep ) ) {
343 return $prep;
344 }
345
346 $session = $prep['session'];
347 $fields = (array) $request->get_param( 'fields' );
348 $payment_method = sanitize_text_field( (string) $request->get_param( 'payment_method' ) );
349 $payment_data = (array) $request->get_param( 'payment_payload' );
350
351 // Re-hydrate cart from the multi-item session if it was emptied between
352 // /state and /place (cross-origin cookies, page reload, etc.). This is
353 // only meaningful for the addon's session-driven flow; same-site cookie
354 // callers usually have the cart still populated.
355 if ( $session ) {
356 $cart = Helper::cart();
357 if ( $cart && method_exists( $cart, 'is_cart_empty' ) && $cart->is_cart_empty() ) {
358 $this->hydrate_cart_from_session( $cart, $session );
359 }
360 }
361
362 // Recalculate against the exact address + shipping method being
363 // submitted before we snapshot totals onto the order and charge the
364 // gateway. Guarantees the customer is charged the amount for the address
365 // they're checking out with, even if a debounced /update didn't land
366 // (fast submit, race) or the totals fragment was stale.
367 self::sync_cart_from_fields( $fields );
368
369 // Pipe the form `fields` into $_POST/$_REQUEST so legacy gateway code
370 // paths (which were written against the admin-ajax flow) can read
371 // scalars like `storeengine-stripe-payment-token` and
372 // `storeengine-stripe-save-new-payment-method` via $_REQUEST. WP REST
373 // doesn't auto-populate these from JSON bodies.
374 self::pipe_legacy_post_keys( $fields );
375
376 // Pipe the gateway-specific payload into $_POST so legacy gateways pick it up.
377 self::pipe_legacy_post_keys( $payment_data );
378
379 /**
380 * Per-gateway opportunity to remap / persist anything the React
381 * adapter sent in `payment_payload` before CheckoutService::place_order()
382 * runs. Gateway addons hook here to copy intent ids, transaction ids,
383 * etc. from $payment_data onto the draft order so their server-side
384 * process_payment() can verify the payment.
385 *
386 * Generic action — fires for every place_order call regardless of
387 * gateway. Specific action — fires only when this particular gateway
388 * was selected, so addons can scope their handler cheaply.
389 *
390 * @param array $payment_data Raw payload from the React adapter.
391 * @param string $payment_method Selected gateway id.
392 */
393 do_action( 'storeengine/checkout/before_place_order_payload', $payment_data, $payment_method );
394 do_action( "storeengine/checkout/before_place_order_payload/{$payment_method}", $payment_data );
395
396 // Build the canonical place_order payload and delegate.
397 $payload = $fields;
398 $payload['payment_method'] = $payment_method;
399
400 $result = CheckoutService::place_order( $payload );
401 if ( is_wp_error( $result ) ) {
402 return $result;
403 }
404
405 // REST callers don't follow the legacy `redirect` URL — the React app
406 // (and any future SPA client) handle navigation themselves. The
407 // traditional /checkout/ page consumes this same response and treats
408 // `redirect` as the URL to navigate to.
409 return rest_ensure_response( $result );
410 }
411
412 /**
413 * Pay an existing failed/pending order. Mirrors the legacy admin-ajax
414 * `pay_order` action — different validation semantics from /place because
415 * the order already exists and we just need to charge a (potentially
416 * different) gateway against it.
417 *
418 * Request body:
419 * { order_id: int, payment_method: string, payment_payload?: object }
420 *
421 * Returns the gateway's process_payment() response (typically
422 * `{ result: 'success', redirect: string }`).
423 */
424 public function pay_order( WP_REST_Request $request ) {
425 if ( ! defined( 'STOREENGINE_DOING_CHECKOUT' ) ) {
426 define( 'STOREENGINE_DOING_CHECKOUT', true );
427 }
428
429 $order_id = (int) $request->get_param( 'order_id' );
430 $payment_method = sanitize_text_field( (string) $request->get_param( 'payment_method' ) );
431 $payment_data = (array) $request->get_param( 'payment_payload' );
432
433 if ( ! $order_id ) {
434 return new WP_Error( 'storeengine_pay_order_missing_id', __( 'Order ID is required.', 'storeengine' ), [ 'status' => 422 ] );
435 }
436 if ( '' === $payment_method ) {
437 return new WP_Error( 'storeengine_pay_order_missing_method', __( 'Payment method is required.', 'storeengine' ), [ 'status' => 422 ] );
438 }
439
440 $order = Helper::get_order( $order_id );
441 if ( ! $order || is_wp_error( $order ) ) {
442 return new WP_Error( 'storeengine_pay_order_not_found', __( 'Order not found.', 'storeengine' ), [ 'status' => 404 ] );
443 }
444
445 // Mirror is_valid_order_pay_page() authorization: the customer owns the
446 // order, OR has the explicit pay_for_order capability, OR carries a
447 // matching order_key (guest-pay link). The legacy check rejected any
448 // logged-in user trying to pay a guest order (customer_id=0), which
449 // broke POS QR sales whenever the customer happened to be logged in
450 // on the site (since the cashier intentionally leaves customer_id=0
451 // so the order-pay page accepts guest pay).
452 $current_user_id = get_current_user_id();
453 $order_customer = (int) $order->get_customer_id();
454 $is_owner = $order_customer > 0 && $order_customer === $current_user_id;
455 $has_cap = current_user_can( 'pay_for_order', $order->get_id() );
456 $provided_key = (string) ( $request->get_param( 'order_key' )
457 ?: ( isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
458 $key_matches = $provided_key && hash_equals( (string) $order->get_order_key(), $provided_key );
459
460 if ( ! $is_owner && ! $has_cap && ! $key_matches ) {
461 return new WP_Error( 'storeengine_pay_order_not_found', __( 'Order not found.', 'storeengine' ), [ 'status' => 404 ] );
462 }
463 if ( ! $order->needs_payment() ) {
464 return new WP_Error( 'storeengine_pay_order_not_payable', __( "Order isn't available for payment!", 'storeengine' ), [ 'status' => 422 ] );
465 }
466
467 $gateway = Helper::get_payment_gateway( $payment_method );
468 if ( ! $gateway ) {
469 return new WP_Error( 'storeengine_pay_order_invalid_gateway', __( 'Invalid payment gateway.', 'storeengine' ), [ 'status' => 422 ] );
470 }
471
472 // `PaymentGateway::is_available()` only sees a subscription via the
473 // cart, which is absent here (this endpoint charges an existing order
474 // directly). Re-check against the order itself so a stale/forged
475 // `payment_method` can't settle a subscription renewal through a
476 // gateway that doesn't support recurring payments (COD, BACS, Check).
477 if ( ! PaymentUtil::gateway_can_pay_order( $gateway, $order ) ) {
478 return new WP_Error( 'storeengine_pay_order_gateway_not_supported', __( 'The selected payment method cannot be used to pay this subscription renewal. Please choose a different payment method.', 'storeengine' ), [ 'status' => 422 ] );
479 }
480
481 // Pipe the payment payload into $_POST so legacy gateway code paths can
482 // read scalars (intent ids, transaction ids, etc.) via $_REQUEST.
483 self::pipe_legacy_post_keys( $payment_data );
484
485 /**
486 * Mirror of the place_order remap hook for the pay-order flow. Gateway
487 * addons hook here to translate React-adapter payload keys (e.g.
488 * `stripe_payment_intent_id` → legacy `payment_intent_id`) and persist
489 * intent ids on the existing order — unlike the place_order variant,
490 * the order already exists and is passed explicitly, so handlers must
491 * not fall back to a draft-order lookup.
492 *
493 * @param array $payment_data Raw payload from the client adapter.
494 * @param string $payment_method Selected gateway id.
495 * @param Order $order The existing order being paid.
496 */
497 do_action( 'storeengine/checkout/before_pay_order_payload', $payment_data, $payment_method, $order );
498 do_action( "storeengine/checkout/before_pay_order_payload/{$payment_method}", $payment_data, $order );
499
500 // Gateways may throw (e.g. Stripe's process_payment rethrows on failure).
501 // Catch here so a payment error returns a clean REST error instead of an
502 // uncaught exception / fatal 500 for the customer paying the order.
503 try {
504 $result = $gateway->process_payment( $order );
505 } catch ( \Throwable $e ) {
506 \StoreEngine\Classes\Logger::log(
507 sprintf( 'Payment Failed - Order #%d', $order->get_id() ),
508 [
509 'customer_email' => $order->get_billing_email(),
510 'message' => $e->getMessage(),
511 'payment_method' => $payment_method,
512 ],
513 \StoreEngine\Classes\Logger::ERROR,
514 'payment'
515 );
516 return new WP_Error( 'storeengine_pay_order_failed', $e->getMessage(), [ 'status' => 422 ] );
517 }
518
519 if ( is_wp_error( $result ) ) {
520 \StoreEngine\Classes\Logger::log(
521 sprintf( 'Payment Failed - Order #%d', $order->get_id() ),
522 [
523 'customer_email' => $order->get_billing_email(),
524 'message' => $result->get_error_message(),
525 'payment_method' => $payment_method,
526 ],
527 \StoreEngine\Classes\Logger::ERROR,
528 'payment'
529 );
530 return $result;
531 }
532
533 $order->set_payment_method( $gateway );
534 $order->save();
535
536 do_action( 'storeengine/checkout/after_pay_order', $order );
537
538 $result['order_id'] = $order->get_id();
539
540 // Same response shape as /place — order data + a fallback redirect to
541 // the order-received page when the gateway didn't supply one.
542 if ( isset( $result['result'] ) && 'success' === $result['result'] ) {
543 $result = CheckoutService::prepare_checkout_response( $order, $result );
544 }
545
546 return rest_ensure_response( $result );
547 }
548
549 /**
550 * Generalised payment-intent creation. Two flows:
551 *
552 * 1. New checkout — no `order_id` in payload. Requires a non-empty cart;
553 * resolves/creates a draft order and snapshots the cart onto it.
554 * 2. Pay-for-existing-order — caller passes `order_id` (and is
555 * authorised to pay it). The existing order is used as-is, no cart
556 * sync, no draft creation. Drives the frontend-dashboard "Pay Order"
557 * flow for failed / scheduled / installment orders.
558 *
559 * Either way, the gateway's GatewayAdapterInterface::create_intent() (or
560 * a soft fallback filter) builds the actual client-side intent.
561 */
562 public function create_payment_intent( WP_REST_Request $request ) {
563 $prep = $this->prepare_request( $request );
564 if ( is_wp_error( $prep ) ) {
565 return $prep;
566 }
567
568 $gateway_id = sanitize_key( (string) $request->get_param( 'gateway_id' ) );
569 $gateway = Helper::get_payment_gateway( $gateway_id );
570 if ( ! $gateway ) {
571 return new WP_Error( 'storeengine_checkout_invalid_gateway', __( 'Unknown payment gateway.', 'storeengine' ), [ 'status' => 422 ] );
572 }
573
574 $cart = Helper::cart();
575 $order_id = absint( $request->get_param( 'order_id' ) );
576
577 if ( $order_id ) {
578 // Pay-for-existing-order path. Skip the cart-empty guard, draft
579 // lookup and cart→order sync entirely — the order already exists
580 // and its totals are authoritative.
581 $order = Helper::get_order( $order_id );
582 if ( is_wp_error( $order ) ) {
583 return new WP_Error( 'storeengine_checkout_invalid_order', __( 'Order not found.', 'storeengine' ), [ 'status' => 404 ] );
584 }
585 if ( ! current_user_can( 'pay_for_order', $order->get_id() ) ) {
586 return new WP_Error( 'storeengine_checkout_forbidden', __( 'You are not allowed to pay for this order.', 'storeengine' ), [ 'status' => 403 ] );
587 }
588 if ( ! $order->needs_payment() ) {
589 return new WP_Error( 'storeengine_checkout_not_payable', __( 'This order is not awaiting payment.', 'storeengine' ), [ 'status' => 422 ] );
590 }
591 if ( ! PaymentUtil::gateway_can_pay_order( $gateway, $order ) ) {
592 return new WP_Error( 'storeengine_checkout_gateway_not_supported', __( 'The selected payment method cannot be used to pay this subscription renewal. Please choose a different payment method.', 'storeengine' ), [ 'status' => 422 ] );
593 }
594 } else {
595 // New-checkout path. Requires a non-empty cart and creates/uses a
596 // draft order with the cart snapshot.
597 if ( ! $cart || $cart->is_cart_empty() ) {
598 return new WP_Error( 'storeengine_checkout_empty_cart', __( 'Cart is empty.', 'storeengine' ), [ 'status' => 422 ] );
599 }
600 // Sync the submitted address + shipping choice (when the gateway
601 // adapter forwards them) and recalculate, so the intent amount the
602 // gateway is about to create matches the address being checked out
603 // with. Falls back to a plain recalc when no fields are sent.
604 self::sync_cart_from_fields( (array) $request->get_param( 'fields' ) );
605
606 $order = Helper::get_recent_draft_order( get_current_user_id(), null, true );
607 if ( ! $order ) {
608 return new WP_Error( 'storeengine_checkout_no_order', __( 'Could not create order.', 'storeengine' ), [ 'status' => 500 ] );
609 }
610
611 // Snapshot the cart onto the draft order so every gateway adapter sees
612 // a fully populated order (line items, coupons, fees, shipping, tax).
613 // Without this, gateways like Paddle — which build their discount from
614 // $order->get_coupons() — would create the intent against the original
615 // totals because the draft has no coupon items yet. Mirrors
616 // CheckoutService::place_order() minus the status transition; the
617 // order stays a DRAFT and gets re-synced when place_order runs.
618 try {
619 $order->clear_items();
620 $order->set_currency( Formatting::get_currency() );
621 CheckoutService::add_product( $order, $cart );
622 CheckoutService::add_fee( $order, $cart );
623 CheckoutService::add_shipping( $order, $cart );
624 CheckoutService::apply_coupon( $order, $cart );
625 CheckoutService::add_tax( $order, $cart );
626 $order->set_cart_hash( $cart->get_cart_hash() );
627 $order->set_total( (float) $cart->get_total( 'edit' ) );
628 $order->save();
629 } catch ( StoreEngineException $e ) {
630 return new WP_Error(
631 $e->get_wp_error_code() ?: 'storeengine_checkout_sync_failed',
632 $e->getMessage(),
633 [ 'status' => 422 ]
634 );
635 } catch ( \Throwable $e ) {
636 return new WP_Error( 'storeengine_checkout_sync_failed', $e->getMessage(), [ 'status' => 500 ] );
637 }
638 }
639
640 // GatewayAdapterInterface::create_intent() requires a Cart instance.
641 // In the order-pay path the user has no cart, but the contract still
642 // needs *something* — instantiate an empty one so gateways which only
643 // read the order (Stripe, Square) work, and gateways which read the
644 // cart (PayPal, Razorpay, Paddle) get a defined empty-state.
645 if ( ! $cart ) {
646 $cart = new \StoreEngine\Classes\Cart();
647 }
648
649 // Pipe scalar JSON-body params into $_REQUEST so create_intent
650 // implementations can read gateway-specific knobs (e.g. Stripe's
651 // `mode=setup`, `save_method=true`) without us threading the WP_REST_Request
652 // through the GatewayAdapterInterface signature.
653 self::pipe_legacy_post_keys( $request->get_params() );
654
655 // Preferred path: gateway implements GatewayAdapterInterface::create_intent.
656 if ( $gateway instanceof \StoreEngine\Interfaces\GatewayAdapterInterface ) {
657 try {
658 $intent = $gateway->create_intent( $order, $cart );
659 } catch ( \Throwable $e ) {
660 return new WP_Error( 'storeengine_checkout_intent_failed', $e->getMessage(), [ 'status' => 500 ] );
661 }
662 if ( is_wp_error( $intent ) ) {
663 return $intent;
664 }
665
666 return rest_ensure_response( (array) $intent );
667 }
668
669 // Soft fallback: let an addon hook in a per-gateway intent creator without
670 // implementing the interface. Filter signature: ( $intent_or_null, $gateway, $order, $cart ).
671 $intent = apply_filters( 'storeengine/checkout/create_intent', null, $gateway, $order, $cart );
672 if ( is_wp_error( $intent ) ) {
673 return $intent;
674 }
675 if ( is_array( $intent ) ) {
676 return rest_ensure_response( $intent );
677 }
678
679 return new WP_Error(
680 'storeengine_checkout_intent_unsupported',
681 sprintf(
682 /* translators: %s: gateway id */
683 __( 'Gateway "%s" does not support client-side payment intents.', 'storeengine' ),
684 $gateway_id
685 ),
686 [ 'status' => 422 ]
687 );
688 }
689
690 /**
691 * Apply a coupon to the cart and return the refreshed snapshot. Mirrors
692 * the legacy admin-ajax `apply_coupon_form` action but speaks REST.
693 */
694 public function apply_coupon( WP_REST_Request $request ) {
695 $prep = $this->prepare_request( $request );
696 if ( is_wp_error( $prep ) ) {
697 return $prep;
698 }
699
700 $code = sanitize_text_field( (string) $request->get_param( 'coupon_code' ) );
701 if ( '' === $code ) {
702 return new WP_Error( 'storeengine_coupon_empty', __( 'Please enter a coupon code.', 'storeengine' ), [ 'status' => 422 ] );
703 }
704
705 $cart = Helper::cart();
706 if ( ! $cart ) {
707 return new WP_Error( 'storeengine_no_cart', __( 'Cart unavailable.', 'storeengine' ), [ 'status' => 500 ] );
708 }
709
710 $result = $cart->apply_coupon( $code );
711 if ( is_wp_error( $result ) ) {
712 return new WP_Error( $result->get_error_code(), $result->get_error_message(), [ 'status' => 422 ] );
713 }
714
715 $cart->calculate_totals();
716
717 return rest_ensure_response( [
718 'applied' => true,
719 'code' => $code,
720 'snapshot' => $this->snapshot( $prep['session'] ),
721 ] );
722 }
723
724 /**
725 * Remove a previously applied coupon and return the refreshed snapshot.
726 */
727 public function remove_coupon( WP_REST_Request $request ) {
728 $prep = $this->prepare_request( $request );
729 if ( is_wp_error( $prep ) ) {
730 return $prep;
731 }
732
733 $code = sanitize_text_field( (string) $request->get_param( 'coupon_code' ) );
734 if ( '' === $code ) {
735 return new WP_Error( 'storeengine_coupon_empty', __( 'Missing coupon code.', 'storeengine' ), [ 'status' => 422 ] );
736 }
737
738 $cart = Helper::cart();
739 if ( ! $cart ) {
740 return new WP_Error( 'storeengine_no_cart', __( 'Cart unavailable.', 'storeengine' ), [ 'status' => 500 ] );
741 }
742
743 $cart->remove_coupon( $code );
744 $cart->calculate_totals();
745
746 return rest_ensure_response( [
747 'removed' => true,
748 'code' => $code,
749 'snapshot' => $this->snapshot( $prep['session'] ),
750 ] );
751 }
752
753 public function get_states( WP_REST_Request $request ) {
754 $cc = strtoupper( sanitize_text_field( (string) $request->get_param( 'country_code' ) ) );
755 $states = Countries::init()->get_states( $cc );
756 $locales = Countries::init()->get_country_locale();
757 $locale = $locales[ $cc ] ?? [];
758
759 return rest_ensure_response( [
760 'country_code' => $cc,
761 'states' => $states ?: (object) [],
762 'label' => $locale['state']['label'] ?? __( 'State / County', 'storeengine' ),
763 'required' => $locale['state']['required'] ?? false,
764 ] );
765 }
766
767 // ---- Internals ---------------------------------------------------------
768
769 /**
770 * Resolve the optional embed session, bootstrap cart/customer, hydrate cart.
771 * Returns either { session: array|null } or WP_Error.
772 */
773 protected function prepare_request( WP_REST_Request $request ) {
774 StoreEngine::init()->load_cart();
775
776 $session_id = sanitize_text_field( (string) $request->get_param( 'session_id' ) );
777 $session = null;
778 if ( $session_id ) {
779 $session = apply_filters( 'storeengine/checkout/resolve_session', null, $session_id, $request );
780 if ( is_wp_error( $session ) ) {
781 return $session;
782 }
783 if ( is_array( $session ) ) {
784 $cart = Helper::cart();
785 if ( $cart && method_exists( $cart, 'is_cart_empty' ) && $cart->is_cart_empty() ) {
786 $this->hydrate_cart_from_session( $cart, $session );
787 }
788 }
789 }
790
791 return [ 'session' => $session ];
792 }
793
794 /**
795 * Replace cart contents with the session's `selection` (multi-item) or fall
796 * back to legacy single product/price/qty fields. This is identical to the
797 * helper used by the addon controller and is exposed via
798 * `storeengine/checkout/hydrate_cart` so the addon's `OneClickService` and
799 * `Stripe-intent` endpoint can call the same logic without a fork.
800 */
801 /**
802 * Reserved superglobal keys that downstream auth/admin code trusts. We
803 * never let a REST-body caller inject values for these via the legacy
804 * gateway-compat $_POST piping below, even if a gateway author somehow
805 * named a parameter the same way.
806 */
807 const RESERVED_REQUEST_KEYS = [
808 'action',
809 '_wpnonce',
810 '_wp_http_referer',
811 '_method',
812 'storeengine_admin_action',
813 'storeengine_nonce',
814 'user_id',
815 'user_login',
816 'user_email',
817 'pwd',
818 ];
819
820 /**
821 * Copy scalar values from a REST-body array into $_POST/$_REQUEST so
822 * legacy gateway code paths (written against admin-ajax) can still read
823 * them via $_REQUEST. Two guards:
824 *
825 * 1. Skip RESERVED_REQUEST_KEYS — caller can't override WP/StoreEngine
826 * internal keys that downstream code trusts for auth/admin flows.
827 * 2. Skip keys already set in $_POST — caller can't overwrite values
828 * the framework already populated for this request.
829 *
830 * Centralized so all three pipe-points (place_order, pay_order,
831 * create_payment_intent) stay consistent.
832 */
833 protected static function pipe_legacy_post_keys( array $source ): void {
834 $reserved = array_flip( self::RESERVED_REQUEST_KEYS );
835 foreach ( $source as $k => $v ) {
836 if ( ! is_scalar( $v ) ) continue;
837 if ( isset( $reserved[ $k ] ) ) continue;
838 if ( isset( $_POST[ $k ] ) ) continue; // phpcs:ignore WordPress.Security.NonceVerification.Missing
839 $_POST[ $k ] = $v; // phpcs:ignore WordPress.Security.NonceVerification.Missing
840 $_REQUEST[ $k ] = $v; // phpcs:ignore WordPress.Security.NonceVerification.Missing
841 }
842 }
843
844 /**
845 * Push the submitted address + chosen shipping method onto the session
846 * cart/customer and recalculate totals.
847 *
848 * Shared by /update (live, on every field change) and /place (final, right
849 * before payment) so shipping + tax always reflect the address the shopper
850 * is actually checking out with — and the amount charged matches the amount
851 * shown. The cart's customer is the same instance as StoreEngine::customer
852 * (Cart sets it in its constructor), so writing here updates what
853 * get_shipping_packages() reads during calculate_totals().
854 *
855 * @param array $fields Checkout field payload (REST `fields`).
856 */
857 private static function sync_cart_from_fields( array $fields ): void {
858 $cart = Helper::cart();
859 if ( ! $cart ) {
860 return;
861 }
862
863 // Apply shipping method choice so totals pick the right rate.
864 if ( ! empty( $fields['shipping_method'] ) ) {
865 $cart->set_meta( 'chosen_shipping_methods', [ sanitize_text_field( $fields['shipping_method'] ) ] );
866 }
867
868 // Persist address bits to the customer so totals see the right location.
869 $customer = StoreEngine::init()->get_customer();
870 if ( $customer ) {
871 $map = [
872 'billing_country', 'billing_state', 'billing_city', 'billing_postcode',
873 'shipping_country', 'shipping_state', 'shipping_city', 'shipping_postal_code',
874 ];
875 foreach ( $map as $key ) {
876 if ( ! isset( $fields[ $key ] ) ) {
877 continue;
878 }
879 $setter = 'set_' . str_replace( 'shipping_postal_code', 'shipping_postcode', $key );
880 if ( method_exists( $customer, $setter ) ) {
881 $customer->{$setter}( sanitize_text_field( $fields[ $key ] ) );
882 }
883 }
884 }
885
886 $cart->calculate_totals();
887 }
888
889 /**
890 * Stable fingerprint of the money-bearing cart totals. Used to decide whether
891 * a field change actually moved a total (and therefore the summary needs a
892 * re-render). Rounded so float noise doesn't cause spurious refreshes.
893 *
894 * @param \StoreEngine\Classes\Cart|null $cart
895 *
896 * @return string
897 */
898 private static function totals_fingerprint( $cart ): string {
899 if ( ! $cart ) {
900 return '';
901 }
902
903 return wp_json_encode( [
904 round( (float) $cart->get_total( 'edit' ), 4 ),
905 round( (float) $cart->get_shipping_total(), 4 ),
906 round( (float) $cart->get_shipping_tax(), 4 ),
907 round( (float) $cart->get_taxes_total( true, false ), 4 ),
908 round( (float) $cart->get_discount_total(), 4 ),
909 round( (float) $cart->get_subtotal(), 4 ),
910 ] );
911 }
912
913 /**
914 * Collect the available shipping rates for the current cart and the chosen
915 * rate id, for the embedded checkout's shipping-method selector.
916 *
917 * Rates come from the packages the cart computed during calculate_totals()
918 * (single-package model — package 0). Returns [ rates[], chosenId ]; rates
919 * is empty when the cart doesn't need shipping or the address isn't complete
920 * enough to quote yet.
921 *
922 * @param \StoreEngine\Classes\Cart|null $cart
923 *
924 * @return array{0: array, 1: string}
925 */
926 private static function collect_shipping_rates( $cart ): array {
927 if ( ! $cart || ! $cart->needs_shipping() ) {
928 return [ [], '' ];
929 }
930
931 $packages = \StoreEngine\Shipping\Shipping::init()->get_packages();
932 $chosen_methods = (array) $cart->get_meta( 'chosen_shipping_methods' );
933
934 $rates = [];
935 $chosen = '';
936
937 foreach ( $packages as $i => $package ) {
938 $package_rates = $package['rates'] ?? [];
939 if ( is_array( $package_rates ) ) {
940 foreach ( $package_rates as $rate ) {
941 if ( ! is_object( $rate ) || ! method_exists( $rate, 'get_id' ) ) {
942 continue;
943 }
944 $rates[] = [
945 'id' => $rate->get_id(),
946 'label' => $rate->get_label(),
947 'cost' => (float) $rate->get_cost(),
948 'tax' => (float) $rate->get_shipping_tax(),
949 'method_id' => $rate->get_method_id(),
950 ];
951 }
952 }
953 $chosen = (string) ( $chosen_methods[ $i ] ?? '' );
954 break; // Single-package model — only package 0 is surfaced.
955 }
956
957 // Fall back to the first rate so the UI always has a selection to show.
958 if ( '' === $chosen && ! empty( $rates ) ) {
959 $chosen = $rates[0]['id'];
960 }
961
962 return [ $rates, $chosen ];
963 }
964
965 public static function hydrate_cart_from_session( $cart, array $session ): void {
966 $selection = isset( $session['selection'] ) ? (array) $session['selection'] : [];
967
968 if ( ! $selection ) {
969 $price_id = (int) ( $session['price_id'] ?? 0 );
970 $quantity = max( 1, (int) ( $session['quantity'] ?? 1 ) );
971 if ( ! $price_id && ! empty( $session['product_id'] ) ) {
972 $product = Helper::get_product( (int) $session['product_id'] );
973 if ( $product ) {
974 $prices = $product->get_prices();
975 if ( $prices ) {
976 $price_id = (int) reset( $prices )->get_id();
977 }
978 }
979 }
980 if ( $price_id ) {
981 $selection[] = [ 'price_id' => $price_id, 'quantity' => $quantity ];
982 }
983 }
984
985 $cart->clear_cart();
986 foreach ( $selection as $row ) {
987 $row = (array) $row;
988 $price_id = (int) ( $row['price_id'] ?? 0 );
989 $qty = max( 1, (int) ( $row['quantity'] ?? 1 ) );
990 if ( ! $price_id && ! empty( $row['product_id'] ) ) {
991 $product = Helper::get_product( (int) $row['product_id'] );
992 if ( $product ) {
993 $prices = $product->get_prices();
994 if ( $prices ) {
995 $price_id = (int) reset( $prices )->get_id();
996 }
997 }
998 }
999 if ( $price_id ) {
1000 $cart->add_product_to_cart( $price_id, $qty );
1001 }
1002 }
1003 }
1004
1005 protected function snapshot( ?array $session = null ): array {
1006 $cart = Helper::cart();
1007 $gateways = [];
1008 $order = Helper::get_recent_draft_order( get_current_user_id(), null, true );
1009 $avail = Helper::get_payment_gateways()->get_available_payment_gateways();
1010 foreach ( $avail as $gateway ) {
1011 $gateways[] = $this->present_gateway( $gateway );
1012 }
1013
1014 $per_item_discounts = method_exists( $cart, 'get_coupon_discount_per_item' ) ? $cart->get_coupon_discount_per_item() : [];
1015 $reward_units = method_exists( $cart, 'get_reward_units' ) ? $cart->get_reward_units() : [];
1016
1017 $items = [];
1018 foreach ( $cart->get_cart_items() as $cart_item_key => $item ) {
1019 $product = Helper::get_product( $item->product_id );
1020
1021 $free_units = 0;
1022 $free_coupon = '';
1023 foreach ( $reward_units as $code => $keys ) {
1024 if ( ! empty( $keys[ $cart_item_key ] ) ) {
1025 $free_units += (int) $keys[ $cart_item_key ];
1026 $free_coupon = (string) $code;
1027 }
1028 }
1029
1030 $items[] = [
1031 'item_key' => $cart_item_key,
1032 'product_id' => (int) $item->product_id,
1033 'price_id' => (int) ( $item->price_id ?? 0 ),
1034 'name' => $product ? $product->get_name() : '',
1035 'image' => $product ? ( get_the_post_thumbnail_url( $product->get_id(), 'thumbnail' ) ?: null ) : null,
1036 'quantity' => (int) $item->quantity,
1037 'price' => (float) ( $item->price ?? 0 ),
1038 'subtotal' => (float) ( $item->subtotal ?? ( $item->price * $item->quantity ) ),
1039 'coupon_discount' => (float) array_sum( $per_item_discounts[ $cart_item_key ] ?? [] ),
1040 'free_units' => $free_units,
1041 'free_units_coupon' => $free_coupon,
1042 // Add-on line markers (e.g. an auto-added gift line).
1043 'item_data' => (array) ( $item->item_data ?? [] ),
1044 ];
1045 }
1046
1047 // Optional declared items + selection from a session (only meaningful
1048 // when called from the embedded React app via the addon).
1049 $declared = [];
1050 $selection = [];
1051 if ( $session && ! empty( $session['items'] ) ) {
1052 foreach ( (array) $session['items'] as $row ) {
1053 $pid = (int) ( $row['product_id'] ?? 0 );
1054 if ( ! $pid ) {
1055 continue;
1056 }
1057 $product = Helper::get_product( $pid );
1058 $pid_price = (int) ( $row['price_id'] ?? 0 );
1059 $price = null;
1060 if ( $product ) {
1061 $prices = $product->get_prices();
1062 if ( $pid_price ) {
1063 foreach ( $prices as $p ) {
1064 if ( (int) $p->get_id() === $pid_price ) {
1065 $price = $p;
1066 break;
1067 }
1068 }
1069 }
1070 if ( ! $price && $prices ) {
1071 $price = reset( $prices );
1072 $pid_price = (int) $price->get_id();
1073 }
1074 }
1075 $declared[] = [
1076 'product_id' => $pid,
1077 'price_id' => $pid_price,
1078 'name' => ! empty( $row['label'] ) ? (string) $row['label'] : ( $product ? $product->get_name() : '' ),
1079 'image' => ! empty( $row['image'] ) ? (string) $row['image'] : ( $product ? ( get_the_post_thumbnail_url( $pid, 'thumbnail' ) ?: null ) : null ),
1080 'price' => $price ? (float) $price->get_price() : 0.0,
1081 'optional' => ! empty( $row['optional'] ),
1082 'default' => ! empty( $row['default'] ),
1083 'quantity' => max( 1, (int) ( $row['quantity'] ?? 1 ) ),
1084 ];
1085 }
1086 }
1087 if ( $session && ! empty( $session['selection'] ) ) {
1088 foreach ( (array) $session['selection'] as $row ) {
1089 $selection[] = [
1090 'product_id' => (int) ( $row['product_id'] ?? 0 ),
1091 'price_id' => (int) ( $row['price_id'] ?? 0 ),
1092 'quantity' => (int) ( $row['quantity'] ?? 1 ),
1093 ];
1094 }
1095 }
1096
1097 $snapshot = [
1098 'cart' => [
1099 'items' => $items,
1100 'needs_shipping' => $cart->needs_shipping(),
1101 'needs_payment' => $cart->needs_payment(),
1102 'currency' => Formatting::get_currency(),
1103 ],
1104 'totals' => apply_filters( 'storeengine/api/checkout/totals', [
1105 'subtotal' => (float) $cart->get_cart_subtotal(),
1106 'shipping' => (float) $cart->get_shipping_total(),
1107 'tax' => (float) $cart->get_taxes_total( true, false ),
1108 'discount' => (float) $cart->get_discount_total(),
1109 'total' => (float) $cart->get_total( 'edit' ),
1110 ], $cart ),
1111 'available_gateways' => $gateways,
1112 'order_id' => $order ? (int) $order->get_id() : 0,
1113 'declared_items' => $declared,
1114 'selection' => $selection,
1115 'checkout_fields' => array_values( CheckoutFields::all() ),
1116 'saved_fields' => $this->collect_saved_fields(),
1117 'branding' => $this->collect_branding(),
1118 'applied_coupons' => $this->collect_applied_coupons( $cart ),
1119 'place_order_label' => apply_filters( 'storeengine/checkout/place_order_button_text', __( 'Place order', 'storeengine' ), $cart->needs_payment() ),
1120 'consent_checkboxes' => apply_filters( 'storeengine/checkout/consent_checkboxes', [] ),
1121 ];
1122
1123 // Available shipping methods (so the embedded React checkout can render a
1124 // rate selector instead of silently using the first method) + the
1125 // currently chosen one.
1126 [ $snapshot['shipping_rates'], $snapshot['chosen_shipping_method'] ] = self::collect_shipping_rates( $cart );
1127
1128 /**
1129 * Allow addons to enrich the checkout state snapshot (e.g. expose the
1130 * verification mode + whether OTP is required before placing the order,
1131 * split rewarded units into a FREE line, or inject coupon suggestions).
1132 *
1133 * @param array $snapshot
1134 * @param Cart $cart
1135 */
1136 return apply_filters( 'storeengine/checkout/state_snapshot', $snapshot, $cart );
1137 }
1138
1139 /**
1140 * Surface applied coupons (code + discount) for the React order summary so
1141 * it can render them as removable pills.
1142 */
1143 protected function collect_applied_coupons( $cart ): array {
1144 $out = [];
1145 if ( ! $cart || ! method_exists( $cart, 'get_coupons' ) ) {
1146 return $out;
1147 }
1148 foreach ( $cart->get_coupons() as $coupon ) {
1149 if ( ! is_object( $coupon ) ) {
1150 continue;
1151 }
1152 $code = '';
1153 if ( method_exists( $coupon, 'get_code' ) ) {
1154 $code = (string) $coupon->get_code();
1155 } elseif ( property_exists( $coupon, 'code' ) ) {
1156 $code = (string) $coupon->code;
1157 }
1158 if ( '' === $code ) {
1159 continue;
1160 }
1161 $out[] = [
1162 'code' => $code,
1163 'discount' => method_exists( $cart, 'get_coupon_discount_amount' ) ? (float) $cart->get_coupon_discount_amount( $code ) : 0.0,
1164 ];
1165 }
1166
1167 return $out;
1168 }
1169
1170 /**
1171 * Storefront branding for the React checkout header — store logo + name.
1172 *
1173 * Logo source order:
1174 * 1. StoreEngine settings → `store_logo` (attachment ID).
1175 * 2. Plugin shipped fallback (`assets/images/full-logo.svg`).
1176 *
1177 * Returns `logo_url = ''` when the merchant has no logo configured AND
1178 * the plugin asset is missing — the React header just hides the logo
1179 * tag in that case.
1180 */
1181 protected function collect_branding(): array {
1182 $logo_id = (int) Helper::get_settings( 'store_logo' );
1183 $logo_url = $logo_id ? (string) wp_get_attachment_url( $logo_id ) : '';
1184 if ( ! $logo_url && defined( 'STOREENGINE_ASSETS_URI' ) ) {
1185 $logo_url = STOREENGINE_ASSETS_URI . 'images/full-logo.svg';
1186 }
1187
1188 return [
1189 'logo_url' => $logo_url,
1190 'store_name' => (string) ( Helper::get_settings( 'store_name' ) ?: get_bloginfo( 'name' ) ),
1191 'site_url' => home_url( '/' ),
1192 ];
1193 }
1194
1195 /**
1196 * Pull the current customer's saved billing/shipping into the React state
1197 * shape so the Quick Checkout form can pre-fill the same way the legacy
1198 * /checkout/ template does.
1199 *
1200 * @return array<string, string>
1201 */
1202 protected function collect_saved_fields(): array {
1203 $customer = StoreEngine::init()->get_customer();
1204 if ( ! $customer ) {
1205 return [];
1206 }
1207
1208 $get = static function ( string $method ) use ( $customer ): string {
1209 if ( ! method_exists( $customer, $method ) ) {
1210 return '';
1211 }
1212 $value = $customer->{$method}();
1213
1214 return is_string( $value ) ? $value : (string) ( $value ?? '' );
1215 };
1216
1217 $out = [
1218 'user_email' => $get( 'get_billing_email' ),
1219 'billing_email' => $get( 'get_billing_email' ),
1220 'billing_first_name' => $get( 'get_billing_first_name' ),
1221 'billing_last_name' => $get( 'get_billing_last_name' ),
1222 'billing_address_1' => $get( 'get_billing_address_1' ),
1223 'billing_address_2' => $get( 'get_billing_address_2' ),
1224 'billing_city' => $get( 'get_billing_city' ),
1225 'billing_state' => $get( 'get_billing_state' ),
1226 'billing_postcode' => $get( 'get_billing_postcode' ),
1227 'billing_country' => $get( 'get_billing_country' ),
1228 'billing_phone' => $get( 'get_billing_phone' ),
1229 'shipping_first_name' => $get( 'get_shipping_first_name' ),
1230 'shipping_last_name' => $get( 'get_shipping_last_name' ),
1231 'shipping_address_1' => $get( 'get_shipping_address_1' ),
1232 'shipping_city' => $get( 'get_shipping_city' ),
1233 'shipping_state' => $get( 'get_shipping_state' ),
1234 // Customer object stores it under `postcode`; React state uses `postal_code`.
1235 'shipping_postal_code' => $get( 'get_shipping_postcode' ),
1236 'shipping_country' => $get( 'get_shipping_country' ),
1237 'shipping_phone' => $get( 'get_shipping_phone' ),
1238 ];
1239
1240 // Only return fields that actually have a value so the React side can
1241 // distinguish "saved" from "blank" with a simple truthiness check.
1242 return array_filter( $out, static fn( $v ) => '' !== $v );
1243 }
1244
1245 /**
1246 * Per-gateway payload for the React adapter (publishable keys, etc.).
1247 * Filterable so addon authors can add their own fields.
1248 */
1249 protected function present_gateway( $gateway ): array {
1250 $id = $gateway->id;
1251 $data = [
1252 'id' => $id,
1253 'title' => method_exists( $gateway, 'get_title' ) ? $gateway->get_title() : ( $gateway->title ?? $id ),
1254 'description' => method_exists( $gateway, 'get_description' ) ? $gateway->get_description() : '',
1255 ];
1256
1257 if ( method_exists( $gateway, 'get_option' ) ) {
1258 $is_production = (bool) $gateway->get_option( 'is_production', true );
1259 $data['is_production'] = $is_production;
1260
1261 $key_type = $is_production ? '' : 'test_';
1262 $pk = $gateway->get_option( $key_type . 'publishable_key' );
1263 if ( $pk ) {
1264 $data['publishable_key'] = $pk;
1265 }
1266 }
1267
1268 return apply_filters( 'storeengine/checkout/gateway/' . $id . '/data', $data, $gateway );
1269 }
1270 }
1271