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

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

1,057 lines 40.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Checkout Service.
4 *
5 * Single source of truth for the order-placement pipeline. Used by the REST
6 * surface (traditional /checkout/ page + embedded React checkout) and by
7 * the subscription addon when materialising a subscription from a cart.
8 *
9 * Pure: never calls wp_send_json_*. Returns array on success or WP_Error on
10 * failure so the caller can shape the response (REST JSON, CLI output, etc.)
11 * however it wants.
12 */
13
14 namespace StoreEngine\Classes;
15
16 use StoreEngine;
17 use StoreEngine\Classes\Cache\OrderCache;
18 use StoreEngine\Classes\Exceptions\StoreEngineException;
19 use StoreEngine\Classes\Order\OrderItemCoupon;
20 use StoreEngine\Classes\Order\OrderItemFee;
21 use StoreEngine\Classes\Order\OrderItemProduct;
22 use StoreEngine\Classes\Order\OrderItemShipping;
23 use StoreEngine\Classes\Order\OrderItemTax;
24 use StoreEngine\Shipping\ShippingRate;
25 use StoreEngine\Utils\CheckoutFields;
26 use StoreEngine\Utils\Formatting;
27 use StoreEngine\Utils\Geolocation;
28 use StoreEngine\Utils\Helper;
29 use StoreEngine\Utils\TaxUtil;
30 use WP_Error;
31
32 if ( ! defined( 'ABSPATH' ) ) {
33 exit;
34 }
35
36 class CheckoutService {
37
38 /**
39 * Place an order with the given checkout payload.
40 *
41 * @param array $payload Checkout fields (mirrors the existing AJAX schema).
42 * @param Order|null $existing_order Optional pre-resolved draft order; if null we look up
43 * the recent draft for the current user.
44 *
45 * @return array|WP_Error On success: { order_id, status, redirect, order, ...gateway-specific }.
46 * On failure: WP_Error with a `status` data key (HTTP code) plus optional fields.
47 */
48 public static function place_order( array $payload, ?Order $existing_order = null ) {
49 try {
50 if ( ! defined( 'STOREENGINE_DOING_CHECKOUT' ) ) {
51 define( 'STOREENGINE_DOING_CHECKOUT', true );
52 }
53
54 $validation = self::validate( $payload );
55 if ( is_wp_error( $validation ) ) {
56 return $validation;
57 }
58 // validate() returns the payload after all validate_data filters have run
59 // (e.g. placeholder-email injection for phone contacts). Propagate those
60 // modifications so apply_addresses() and create_customer() see the
61 // corrected values rather than the raw pre-filter input.
62 $payload = $validation;
63
64 $cart = Helper::cart();
65 if ( $cart->needs_shipping() ) {
66 if ( empty( $payload['shipping_method'] ) && empty( $cart->get_meta( 'chosen_shipping_methods' ) ) ) {
67 return new WP_Error(
68 'storeengine_checkout_no_shipping',
69 __( 'Sorry, this order requires a shipping option.', 'storeengine' ),
70 [ 'status' => 422 ]
71 );
72 }
73 }
74
75 $order = $existing_order;
76 if ( ! $order ) {
77 $order = Helper::get_recent_draft_order( get_current_user_id(), null, false );
78 }
79 if ( ! $order ) {
80 return new WP_Error(
81 'storeengine_checkout_no_order',
82 __( 'Order not found.', 'storeengine' ),
83 [ 'status' => 404 ]
84 );
85 }
86
87 do_action( 'storeengine/frontend/checkout/before_place_order', $order );
88
89 // Prepare order data.
90 $order->clear_items();
91 $order->set_currency( Formatting::get_currency() );
92 $order->set_order_placed_date_gmt();
93 $order->set_order_placed_date();
94
95 self::apply_addresses( $order, $payload );
96 self::subscribe_to_email( $payload );
97
98 $gateway = null;
99 if ( $cart->needs_payment() ) {
100 $gateway = Helper::get_payment_gateway( $payload['payment_method'] ?? '' );
101 if ( ! $gateway ) {
102 return new WP_Error(
103 'storeengine_checkout_invalid_gateway',
104 __( 'Invalid payment gateway.', 'storeengine' ),
105 [ 'status' => 422 ]
106 );
107 }
108 $order->set_payment_method( $gateway );
109 } else {
110 $order->set_payment_method( '' );
111 }
112
113 $order->set_customer_id( apply_filters( 'storeengine/frontend/checkout/customer_id', get_current_user_id() ) );
114 $order->set_prices_include_tax( TaxUtil::prices_include_tax() );
115 $order->set_ip_address( Geolocation::get_user_ip() );
116 $order->set_user_agent( Helper::get_user_agent() );
117
118 if ( ! empty( $payload['order_note'] ) ) {
119 $order->set_customer_note( (string) $payload['order_note'] );
120 }
121
122 $customer_obj = StoreEngine::init()->customer;
123 if ( $customer_obj ) {
124 $order->update_meta_data( 'is_vat_exempt', $customer_obj->get_is_vat_exempt() );
125 }
126
127 // Order items — snapshot the cart onto the order.
128 self::add_product( $order, $cart );
129 self::add_fee( $order, $cart );
130 self::add_subscription_setup_fees( $order, $cart );
131 self::add_shipping( $order, $cart );
132 self::apply_coupon( $order, $cart );
133 self::add_tax( $order, $cart );
134
135 $order->set_cart_hash( $cart->get_cart_hash() );
136
137 $customer = self::create_or_update_customer( $order );
138 if ( is_wp_error( $customer ) ) {
139 return $customer;
140 }
141
142 $order_context = new OrderContext( $order->get_status() );
143 $order_context->proceed_to_next_status( 'order_placed', $order );
144
145 // Save before payment so the gateway can read order id / meta.
146 $order->save();
147 $order->read( true );
148
149 // Reserve stock for the next 60 minutes so concurrent checkouts can't oversell.
150 $reservation_items = [];
151 foreach ( $order->get_items( 'line_item' ) as $line_item ) {
152 $reservation_items[] = [
153 'product_id' => method_exists( $line_item, 'get_product_id' ) ? (int) $line_item->get_product_id() : 0,
154 'variation_id' => method_exists( $line_item, 'get_variation_id' ) ? (int) $line_item->get_variation_id() : 0,
155 'quantity' => method_exists( $line_item, 'get_quantity' ) ? (int) $line_item->get_quantity() : 0,
156 ];
157 }
158 \StoreEngine\Classes\StockManager::reserve_stock_for_order( $order->get_id(), $reservation_items, 60 );
159
160 // Capture coupons before the cart is cleared.
161 $coupons = $cart->get_coupons();
162
163 do_action( 'storeengine/checkout/order_processed', $order, $payload );
164
165 $result = [];
166 if ( $cart->needs_payment() && $gateway ) {
167 $payment = $gateway->process_payment( $order );
168 if ( is_wp_error( $payment ) ) {
169 return $payment;
170 }
171 if ( is_array( $payment ) ) {
172 $result = $payment;
173 }
174 } else {
175 // Mark paid before transitioning — OrderContext::proceed_to_next_status()
176 // promotes Processing → Completed only when paid_status is already 'paid'
177 // and the order is flagged as digital auto-complete. Setting paid after
178 // the transition would skip that branch and leave a digital free order
179 // stuck in Processing. If the transition throws, revert the in-memory
180 // paid_status so a later save() can't persist a paid-but-not-advanced state.
181 $order->set_paid_status( 'paid' );
182 try {
183 $ctx2 = new OrderContext( $order->get_status() );
184 $ctx2->proceed_to_next_status( 'processing', $order );
185 } catch ( \Throwable $e ) {
186 $order->set_paid_status( 'unpaid' );
187 throw $e;
188 }
189 $order->save();
190 $result = [
191 'result' => 'success',
192 'redirect' => $order->get_checkout_order_received_url(),
193 ];
194 }
195
196 do_action( 'storeengine/checkout/after_place_order', $order, $payload );
197
198 self::update_coupon_usage( $coupons, $order );
199
200 $result['order_id'] = $order->get_id();
201 $result['status'] = $order->get_status();
202
203 if ( isset( $result['result'] ) && 'success' === $result['result'] ) {
204 $result = self::prepare_checkout_response( $order, $result );
205 }
206
207 $cart->clear_cart();
208
209 return $result;
210 } catch ( StoreEngineException $e ) {
211 $customer_email = $payload['billing_email'] ?? ( $payload['user_email'] ?? 'Unknown Email' );
212
213 Logger::log(
214 sprintf( 'Checkout Failed (%s)', $customer_email ),
215 [
216 'customer_email' => $customer_email,
217 'message' => $e->getMessage(),
218 'code' => $e->get_wp_error_code(),
219 'trace' => $e->getTraceAsString(),
220 'payload' => $payload,
221 ],
222 Logger::ERROR,
223 'checkout'
224 );
225
226 return new WP_Error(
227 $e->get_wp_error_code() ?: 'storeengine_checkout_failed',
228 $e->getMessage(),
229 [ 'status' => 500 ]
230 );
231 }
232 }
233
234 /**
235 * Recalculate cart with new field values (address change, shipping, etc.).
236 *
237 * @return array Response payload (mirrors the legacy `update_checkout` AJAX shape minus
238 * the wp_send_json wrapper).
239 */
240 public static function update_checkout( array $data ): array {
241 $data = apply_filters( 'storeengine/frontend/checkout/before_update_draft_order', $data );
242 $draft_order = Helper::get_recent_draft_order();
243 $old_payment_method = $draft_order ? $draft_order->get_payment_method( 'edit' ) : '';
244
245 $old_shipping_method = Helper::cart()->get_meta( 'chosen_shipping_methods' );
246 $old_shipping_method = $old_shipping_method ? reset( $old_shipping_method ) : false;
247
248 // Snapshot the PREVIOUS address from the draft order before
249 // apply_addresses() overwrites it. We can't read "old" from the cart
250 // customer here: the REST layer (Checkout::sync_cart_from_fields) has
251 // already written the new address onto it, so a customer-vs-payload diff
252 // would always be equal and the `refresh` flag below would never flip —
253 // leaving the totals/shipping fragment stale until a full page reload.
254 $old_address_snapshot = [];
255 if ( $draft_order ) {
256 $old_address_getters = [
257 'billing_city' => 'get_billing_city',
258 'billing_state' => 'get_billing_state',
259 'billing_postcode' => 'get_billing_postcode',
260 'billing_country' => 'get_billing_country',
261 'shipping_city' => 'get_shipping_city',
262 'shipping_state' => 'get_shipping_state',
263 'shipping_postal_code' => 'get_shipping_postcode',
264 'shipping_country' => 'get_shipping_country',
265 ];
266 foreach ( $old_address_getters as $key => $getter ) {
267 $old_address_snapshot[ $key ] = method_exists( $draft_order, $getter ) ? (string) $draft_order->$getter( 'edit' ) : '';
268 }
269 }
270
271 self::apply_addresses( $draft_order, $data );
272 $draft_order->set_cart_hash( Helper::cart()->get_cart_hash() );
273 $draft_order->save();
274
275 do_action( 'storeengine/frontend/checkout/update_checkout', $draft_order );
276 $data['order'] = $draft_order->get_id();
277
278 $response = [
279 'order' => $data,
280 'massage' => esc_html__( 'Order updated successfully.', 'storeengine' ),
281 'hash' => $draft_order->get_hash(),
282 'refresh_payment_methods' => false,
283 'needs_shipping' => StoreEngine::init()->get_cart()->needs_shipping(),
284 'same_as_shipping' => $data['same_as_shipping'] ?? false,
285 ];
286
287 if ( TaxUtil::is_tax_enabled() || \StoreEngine\Utils\ShippingUtils::is_shipping_enabled() ) {
288 $keys = [
289 'billing_city', 'billing_state', 'billing_postcode', 'billing_country',
290 'shipping_city', 'shipping_state', 'shipping_postal_code', 'shipping_country',
291 ];
292 $new_address = [];
293 $old_address = [];
294 foreach ( $keys as $key ) {
295 $new_val = (string) ( $data[ $key ] ?? '' );
296 $old_val = $old_address_snapshot[ $key ] ?? '';
297 if ( str_starts_with( $key, 'billing_' ) ) {
298 $new_address['billing'][] = $new_val;
299 $old_address['billing'][] = $old_val;
300 } else {
301 $new_address['shipping'][] = $new_val;
302 $old_address['shipping'][] = $old_val;
303 }
304 }
305
306 if ( ! StoreEngine::init()->get_cart()->needs_shipping() ) {
307 unset( $new_address['shipping'], $old_address['shipping'] );
308 }
309
310 $response['refresh'] = md5( wp_json_encode( $new_address ) ) !== md5( wp_json_encode( $old_address ) );
311
312 if ( $old_shipping_method && ! empty( $data['shipping_method'] ) && $data['shipping_method'] !== $old_shipping_method ) {
313 $response['refresh'] = true;
314 }
315 }
316
317 $new_payment_method = $draft_order->get_payment_method( 'edit' );
318 if ( $old_payment_method !== $new_payment_method ) {
319 $response['refresh_payment_methods'] = true;
320 } elseif ( 'cod' === $new_payment_method && ! empty( $response['refresh'] ) ) {
321 $response['refresh_payment_methods'] = true;
322 }
323
324 return $response;
325 }
326
327 /**
328 * Validate the place-order payload. Returns the (possibly-augmented) data on success,
329 * WP_Error with a `fields` data key on failure.
330 */
331 public static function validate( array $data ) {
332 /**
333 * Allow addons to pre-process / gate the payload before validation runs
334 * (e.g. inject a synthesized email in phone-only mode, or reject an
335 * unverified contact). Returning a WP_Error short-circuits the checkout.
336 *
337 * @param array $data Checkout payload.
338 */
339 $data = apply_filters( 'storeengine/checkout/validate_data', $data );
340 if ( is_wp_error( $data ) ) {
341 return $data;
342
343 }
344 $pre_validation = apply_filters( 'storeengine/checkout/validate', null, $data );
345 if ( is_wp_error( $pre_validation ) ) {
346 return $pre_validation;
347 }
348
349 $cart = Helper::cart();
350 $needs_shipping = $cart ? $cart->needs_shipping() : false;
351 $required_fields = CheckoutFields::required_payload_keys( $needs_shipping, (string) ( $data['shipping_country'] ?? '' ) );
352
353 // `billing_email` shouldn't be required separately if `email` (user_email)
354 // is the canonical contact field — make sure we accept either when both
355 // are listed as required.
356 if ( in_array( 'user_email', $required_fields, true ) && empty( $data['user_email'] ) && ! empty( $data['billing_email'] ) ) {
357 $data['user_email'] = $data['billing_email'];
358 }
359
360 $missing_fields = [];
361
362 if ( $cart && $cart->needs_payment() ) {
363 $required_fields[] = 'payment_method';
364 }
365
366 foreach ( $required_fields as $field ) {
367 if ( empty( $data[ $field ] ) ) {
368 $missing_fields[] = $field;
369 }
370 }
371
372 if ( ! empty( $missing_fields ) ) {
373 // Name the actual missing fields (e.g. "Please fill in the required
374 // field(s): City, Postcode") instead of a generic prompt, so the
375 // shopper knows exactly what to fix. Both checkout surfaces render
376 // this message verbatim, so this single change covers both.
377 $labels_by_key = [];
378 foreach ( CheckoutFields::all() as $row ) {
379 $labels_by_key[ $row['payload_key'] ] = $row['label'];
380 }
381 $labels_by_key['payment_method'] = __( 'Payment method', 'storeengine' );
382
383 $missing_labels = array_values( array_unique( array_map(
384 static function ( $key ) use ( $labels_by_key ) {
385 return $labels_by_key[ $key ] ?? $key;
386 },
387 $missing_fields
388 ) ) );
389
390 return new WP_Error(
391 'storeengine_checkout_missing_fields',
392 sprintf(
393 /* translators: %s: comma-separated list of the missing required field labels. */
394 __( 'Please fill in the required field(s): %s', 'storeengine' ),
395 implode( ', ', $missing_labels )
396 ),
397 [ 'status' => 422, 'fields' => $missing_fields ]
398 );
399 }
400
401 // Email format check — prevents `set_order_email` from throwing a
402 // fatal further down the place-order pipeline. Addons can drop the
403 // requirement (e.g. phone-only mode) via `storeengine/checkout/require_email`.
404 $require_email = (bool) apply_filters( 'storeengine/checkout/require_email', true, $data );
405 if ( $require_email && ! empty( $data['user_email'] ) && ! is_email( $data['user_email'] ) ) {
406 return new WP_Error(
407 'storeengine_checkout_invalid_email',
408 __( 'Please enter a valid email address.', 'storeengine' ),
409 [ 'status' => 422, 'fields' => [ 'user_email' ] ]
410 );
411 }
412
413 // Country gate mirroring the conventional checkout validation,
414 // rejecting a billing/shipping country that's outside the allowed list.
415 // The storefront's country `<select>` already filters to the allow-list
416 // at render time (frontend/functions.php:868-880); this is the
417 // server-side safety net for direct REST submissions / scripted clients.
418 //
419 // Trusted-operator contexts (POS, admin-created orders, subscription
420 // renewals) don't route through this validate() call and are
421 // structurally bypassed — matches the "validate_checkout only runs
422 // on storefront flow" pattern.
423 $allowed_sell = array_keys( \StoreEngine\Classes\Countries::init()->get_allowed_countries() );
424 if ( ! empty( $allowed_sell ) && ! empty( $data['billing_country'] ) && ! in_array( $data['billing_country'], $allowed_sell, true ) ) {
425 return new WP_Error(
426 'storeengine_checkout_country_restricted',
427 __( 'We do not currently sell to your billing country. Please contact us if you believe this is an error.', 'storeengine' ),
428 [ 'status' => 422, 'fields' => [ 'billing_country' ] ]
429 );
430 }
431
432 if ( $needs_shipping && ! empty( $data['shipping_country'] ) ) {
433 $allowed_ship = array_keys( \StoreEngine\Classes\Countries::init()->get_shipping_countries() );
434 if ( ! empty( $allowed_ship ) && ! in_array( $data['shipping_country'], $allowed_ship, true ) ) {
435 return new WP_Error(
436 'storeengine_checkout_ship_country_restricted',
437 __( 'We do not currently ship to the selected shipping country.', 'storeengine' ),
438 [ 'status' => 422, 'fields' => [ 'shipping_country' ] ]
439 );
440 }
441 }
442
443 $coupons = $cart->get_coupons();
444 if ( empty( $coupons ) || is_user_logged_in() || ! $cart->has_items() ) {
445 return $data;
446 }
447
448 foreach ( $coupons as $coupon ) {
449 $is_valid = $coupon->validate_coupon( false );
450 if ( is_wp_error( $is_valid ) ) {
451 return new WP_Error(
452 $is_valid->get_error_code() ?: 'storeengine_checkout_coupon_invalid',
453 $is_valid->get_error_message(),
454 [ 'status' => 422 ]
455 );
456 }
457
458 if ( $coupon->get_usage_limit_per_user() > 0 ) {
459 $user = get_user_by( 'email', $data['user_email'] ?? '' );
460 if ( ! $user ) {
461 continue;
462 }
463 if ( $coupon->get_usage_by_user_id( $user->ID ) >= $coupon->get_usage_limit_per_user() ) {
464 return new WP_Error(
465 'storeengine_checkout_coupon_limit',
466 __( 'Sorry, Coupon has reached its limit', 'storeengine' ),
467 [ 'status' => 422 ]
468 );
469 }
470 }
471 }
472
473 return $data;
474 }
475
476 /**
477 * Hydrate billing + shipping props on an order from the checkout payload.
478 * Mirrors the legacy `Ajax\Checkout::set_checkout_data` exactly.
479 */
480 public static function apply_addresses( Order $order, array $data ): void {
481 OrderCache::delete_draft_order();
482
483 // Form-encoded radio values arrive here as the literal strings "true" /
484 // "false". A naive `(bool) "false"` returns true (non-empty string),
485 // which silently overwrites a customer's "Use a different billing
486 // address" choice on every checkout-field debounce. Route through the
487 // shared tolerant coercer instead — same one CheckoutFields uses for
488 // its enabled/required flags.
489 $same_as_shipping = isset( $data['same_as_shipping'] )
490 ? CheckoutFields::to_bool( $data['same_as_shipping'] )
491 : false;
492
493 $need_shipping = StoreEngine::init()->get_cart()->needs_shipping();
494
495 // Persist the customer's explicit choice on the cart so the next
496 // render of billing-address.php can honour it (otherwise the template
497 // falls back to comparing the two addresses and re-flips the radio
498 // whenever they happen to match).
499 if ( $need_shipping ) {
500 Helper::cart()->set_meta( 'same_as_shipping', $same_as_shipping ? 'yes' : 'no' );
501 }
502
503 if ( $need_shipping && $same_as_shipping ) {
504 $data['billing_first_name'] = $data['shipping_first_name'] ?? '';
505 $data['billing_last_name'] = $data['shipping_last_name'] ?? '';
506 $data['billing_address_1'] = $data['shipping_address_1'] ?? '';
507 $data['billing_address_2'] = $data['shipping_address_2'] ?? '';
508 $data['billing_city'] = $data['shipping_city'] ?? '';
509 $data['billing_state'] = $data['shipping_state'] ?? '';
510 $data['billing_postcode'] = $data['shipping_postal_code'] ?? '';
511 $data['billing_country'] = $data['shipping_country'] ?? '';
512 $data['billing_email'] = $data['user_email'] ?? '';
513 $data['billing_phone'] = $data['shipping_phone'] ?? '';
514 }
515
516 // Only persist a syntactically valid email — `update_checkout` runs on
517 // every debounced field change while the shopper is typing, so a
518 // partial value like "hello" reaches here long before the address is
519 // complete. set_order_email() throws on bad input, which used to
520 // surface as a 500 fatal in the REST endpoint. Final-submit validation
521 // happens in self::validate() before this method runs.
522 $candidate_email = $data['user_email'] ?? '';
523 if ( $candidate_email && is_email( $candidate_email ) ) {
524 $order->set_order_email( $candidate_email );
525 }
526 $order->set_currency( Formatting::get_currency() );
527
528 $billing_address = [
529 'billing_first_name' => $data['billing_first_name'] ?? '',
530 'billing_last_name' => $data['billing_last_name'] ?? '',
531 'billing_address_1' => $data['billing_address_1'] ?? '',
532 'billing_address_2' => $data['billing_address_2'] ?? '',
533 'billing_country' => $data['billing_country'] ?? '',
534 'billing_state' => $data['billing_state'] ?? '',
535 'billing_city' => $data['billing_city'] ?? '',
536 'billing_postcode' => $data['billing_postcode'] ?? '',
537 'billing_email' => $data['billing_email'] ?? '',
538 'billing_phone' => $data['billing_phone'] ?? '',
539 ];
540 $order->set_props( $billing_address );
541
542 if ( ! empty( $data['shipping_method'] ) ) {
543 Helper::cart()->set_meta( 'chosen_shipping_methods', [ $data['shipping_method'] ] );
544 }
545 // If no method was supplied we deliberately leave the existing
546 // `chosen_shipping_methods` meta in place. A later step (after
547 // $order->save()) will default it to the first available method per
548 // package — clearing it here would discard that auto-selection on
549 // every checkout-field debounce and re-introduce the "Sorry, this
550 // order requires a shipping option" error on submit.
551
552 if ( ! $need_shipping ) {
553 $shipping_address = [
554 'shipping_first_name' => $data['billing_first_name'] ?? '',
555 'shipping_last_name' => $data['billing_last_name'] ?? '',
556 'shipping_address_1' => $data['billing_address_1'] ?? '',
557 'shipping_address_2' => $data['billing_address_2'] ?? '',
558 'shipping_country' => $data['billing_country'] ?? '',
559 'shipping_state' => $data['billing_state'] ?? '',
560 'shipping_city' => $data['billing_city'] ?? '',
561 'shipping_postcode' => $data['billing_postcode'] ?? '',
562 'shipping_email' => $data['billing_email'] ?? '',
563 'shipping_phone' => $data['billing_phone'] ?? '',
564 ];
565 } else {
566 $shipping_address = [
567 'shipping_first_name' => $data['shipping_first_name'] ?? '',
568 'shipping_last_name' => $data['shipping_last_name'] ?? '',
569 'shipping_address_1' => $data['shipping_address_1'] ?? '',
570 'shipping_address_2' => $data['shipping_address_2'] ?? '',
571 'shipping_country' => $data['shipping_country'] ?? '',
572 'shipping_state' => $data['shipping_state'] ?? '',
573 'shipping_city' => $data['shipping_city'] ?? '',
574 'shipping_postcode' => $data['shipping_postal_code'] ?? '',
575 'shipping_email' => $data['shipping_email'] ?? '',
576 'shipping_phone' => $data['shipping_phone'] ?? '',
577 ];
578 }
579 $order->set_props( $shipping_address );
580
581 if ( isset( $data['payment_method'] ) ) {
582 $order->set_payment_method( Helper::get_payment_gateway( $data['payment_method'] ) );
583 }
584
585 $order->set_shipping_total( Helper::cart()->get_shipping_total() );
586 $order->set_shipping_tax( Helper::cart()->get_shipping_tax() );
587 $order->set_discount_total( Helper::cart()->get_discount_total() );
588 $order->set_discount_tax( Helper::cart()->get_discount_tax() );
589 $order->set_cart_tax( Helper::cart()->get_cart_contents_tax() + Helper::cart()->get_fee_tax() );
590 $order->set_total( Helper::cart()->get_total( 'edit' ) );
591
592 $order->save();
593
594 // Default chosen_shipping_methods to the first available method per
595 // package when shipping is required but the shopper hasn't picked one.
596 // Matches the template-side fallback in cart-shipping.php and ensures
597 // place_order's "needs a shipping option" guard passes even if the
598 // user submits without ever touching the radio.
599 if ( $need_shipping && empty( $data['shipping_method'] ) ) {
600 $existing = (array) Helper::cart()->get_meta( 'chosen_shipping_methods' );
601 $packages = \StoreEngine\Shipping\Shipping::init()->get_packages();
602 $resolved = [];
603 foreach ( $packages as $i => $package ) {
604 if ( ! empty( $existing[ $i ] ) ) {
605 $resolved[ $i ] = $existing[ $i ];
606 continue;
607 }
608 $rates = $package['rates'] ?? [];
609 if ( ! empty( $rates ) && is_array( $rates ) ) {
610 $first = reset( $rates );
611 $resolved[ $i ] = $first ? $first->get_id() : '';
612 }
613 }
614 if ( $resolved ) {
615 Helper::cart()->set_meta( 'chosen_shipping_methods', $resolved );
616 }
617 }
618 }
619
620 /**
621 * Subscribe the customer to email if the payload requests it.
622 */
623 public static function subscribe_to_email( array $data ): void {
624 if ( isset( $data['subscribe_to_email'] ) && StoreEngine::init()->customer ) {
625 $customer = StoreEngine::init()->customer;
626 $customer->set_subscribe_to_email( true );
627 $customer->save();
628 }
629 }
630
631 /**
632 * Create a customer record for a guest order or update an existing one with
633 * the new addresses. Returns Customer or WP_Error.
634 */
635 public static function create_or_update_customer( Order $order ) {
636 $customer_id = $order->get_customer_id();
637 if ( ! $customer_id ) {
638 $customer_id = self::create_customer(
639 $order->get_billing_first_name(),
640 $order->get_billing_last_name(),
641 $order->get_order_email()
642 );
643
644 if ( is_wp_error( $customer_id ) ) {
645 return $customer_id;
646 }
647 $order->set_customer_id( $customer_id );
648 $order->save();
649 }
650
651 $customer = Helper::get_customer( $customer_id );
652
653 // Save Billing details.
654 $customer->set_billing_first_name( $order->get_billing_first_name() );
655 $customer->set_billing_last_name( $order->get_billing_last_name() );
656 $customer->set_billing_address_1( $order->get_billing_address_1() );
657 $customer->set_billing_address_2( $order->get_billing_address_2() );
658 $customer->set_billing_city( $order->get_billing_city() );
659 $customer->set_billing_state( $order->get_billing_state() );
660 $customer->set_billing_postcode( $order->get_billing_postcode() );
661 $customer->set_billing_country( $order->get_billing_country() );
662 $customer->set_billing_phone( $order->get_billing_phone() );
663 $customer->set_billing_email( $order->get_billing_email() );
664
665 if ( $order->needs_shipping_address() ) {
666 $customer->set_shipping_first_name( $order->get_shipping_first_name() );
667 $customer->set_shipping_last_name( $order->get_shipping_last_name() );
668 $customer->set_shipping_address_1( $order->get_shipping_address_1() );
669 $customer->set_shipping_address_2( $order->get_shipping_address_2() );
670 $customer->set_shipping_city( $order->get_shipping_city() );
671 $customer->set_shipping_state( $order->get_shipping_state() );
672 $customer->set_shipping_postcode( $order->get_shipping_postcode() );
673 $customer->set_shipping_country( $order->get_shipping_country() );
674 $customer->set_shipping_phone( $order->get_shipping_phone() );
675 $customer->set_shipping_email( $order->get_shipping_email() );
676 }
677
678 $customer->save();
679
680 return $customer;
681 }
682
683 protected static function create_customer( string $first_name, string $last_name, string $email ) {
684 $email_exists = email_exists( $email );
685 if ( $email_exists ) {
686 // An account already uses this email. A guest checkout must NOT silently
687 // bind to (and then overwrite) another user's customer record — otherwise
688 // anyone who knows a victim's email could attach an order to their account
689 // and clobber their stored billing/shipping data. Require the shopper to
690 // log in (mirrors the conventional storefront behaviour). A logged-in request that already matches
691 // its own account falls through to the normal update path.
692 if ( get_current_user_id() === (int) $email_exists ) {
693 return $email_exists;
694 }
695
696 return new WP_Error(
697 'storeengine_email_belongs_to_existing_account',
698 __( 'An account is already registered with this email address. Please log in to continue.', 'storeengine' ),
699 [
700 // Surface an actionable login URL (returns to checkout, email
701 // pre-filled) so the UI can render a real "Log in to continue"
702 // button next to the message instead of a dead-end error.
703 'status' => 409,
704 'login_url' => storeengine_checkout_login_url( $email ),
705 ]
706 );
707 }
708
709 $base_username = strstr( $email, '@', true );
710 $username = $base_username;
711 $counter = 1;
712
713 while ( username_exists( $username ) ) {
714 $username = $base_username . $counter;
715 $counter ++;
716 }
717
718 $userdata = apply_filters( 'storeengine/checkout/create_customer_data', [
719 'user_login' => $username,
720 'user_email' => $email,
721 'role' => 'storeengine_customer',
722 'display_name' => $first_name . ' ' . $last_name,
723 'first_name' => $first_name,
724 'last_name' => $last_name,
725 ] );
726
727 $userdata['user_pass'] = wp_generate_password();
728
729 $user_id = wp_insert_user( $userdata );
730
731 if ( ! is_wp_error( $user_id ) ) {
732 wp_signon( [
733 'user_login' => $username,
734 'user_password' => $userdata['user_pass'],
735 'remember' => true,
736 ], is_ssl() );
737 do_action( 'storeengine/checkout/customer_created', $user_id, $userdata );
738 }
739
740 return $user_id;
741 }
742
743 /**
744 * @param Coupon[] $coupons
745 */
746 public static function update_coupon_usage( array $coupons, Order $order ): void {
747 foreach ( $coupons as $coupon ) {
748 add_post_meta( $coupon->get_id(), '_storeengine_coupon_used_by', $order->get_customer_id() );
749 $usage_count = (int) get_post_meta( $coupon->get_id(), '_storeengine_coupon_usage_count', true );
750 update_post_meta( $coupon->get_id(), '_storeengine_coupon_usage_count', $usage_count + 1 );
751 }
752 }
753
754 /**
755 * Shape the success response with order data, dates, line items, and a redirect URL.
756 */
757 public static function prepare_checkout_response( Order $order, array $result ): array {
758 $data = $order->get_data();
759 $data['meta'] = $data['meta_data'] ?? [];
760 unset( $data['meta_data'] );
761
762 foreach ( [ 'line_items', 'tax_lines', 'shipping_lines', 'fee_lines', 'coupon_lines' ] as $type ) {
763 if ( ! empty( $data[ $type ] ) ) {
764 $data[ $type ] = array_values( array_map( static function ( $item ) {
765 return array_merge( $item->get_data(), [ 'meta' => $item->get_meta_data() ] );
766 }, $data[ $type ] ) );
767 }
768 }
769
770 foreach ( [ 'date_created_gmt', 'date_updated_gmt', 'date_paid_gmt', 'date_completed_gmt', 'order_placed_date_gmt' ] as $date_prop ) {
771 if ( ! empty( $data[ $date_prop ] ) && is_a( $data[ $date_prop ], StoreengineDatetime::class ) ) {
772 $data[ $date_prop ] = $data[ $date_prop ]->format( 'Y-m-d H:i:s' );
773 }
774 }
775
776 $result['order'] = $data;
777
778 if ( empty( $result['redirect'] ) ) {
779 $result['redirect'] = $order->get_checkout_order_received_url();
780 }
781
782 return apply_filters( 'storeengine/checkout/payment_successful', $result, $order->get_id() );
783 }
784
785 /**
786 * Snapshot cart line items onto the order as OrderItemProduct rows.
787 *
788 * @throws StoreEngineException When a referenced price/variation can't be resolved.
789 */
790 public static function add_product( Order &$order, Cart $cart ) {
791 $coupon_discount_per_item = (array) $cart->get_coupon_discount_per_item();
792
793 foreach ( $cart->get_cart_items() as $item_key => $values ) {
794 $item = new OrderItemProduct();
795 $price = new Price( $values->price_id );
796
797 if ( ! $price->get_id() ) {
798 throw new StoreEngineException( esc_html__( 'Price not found!', 'storeengine' ), 'not_found_price' );
799 }
800
801 if ( ! $values->name ) {
802 $values->name = $price->get_product_title();
803 }
804
805 $line_subtotal = (float) ( $values->line_subtotal ?? 0 );
806 $line_total = (float) ( $values->line_total ?? 0 );
807
808 // The cart bakes coupon discounts into line_total for most items, but
809 // NOT for subscription line items — their line_total stays at the
810 // pre-discount base. That leaves line_total == line_subtotal, so
811 // Order::calculate_totals() (which derives the discount from
812 // subtotal - total across line items) recomputes a zero discount and
813 // drops the coupon from the order totals and emails. When this item
814 // has a coupon discount that the cart failed to apply to line_total,
815 // re-derive line_total from the pre-discount subtotal minus this
816 // item's discount share so every product type carries the discount
817 // consistently. Items whose line_total is already discounted
818 // (line_total < line_subtotal) are left untouched.
819 $item_discount = isset( $coupon_discount_per_item[ $item_key ] ) ? (float) array_sum( (array) $coupon_discount_per_item[ $item_key ] ) : 0;
820 if ( $item_discount > 0 && $line_total >= $line_subtotal ) {
821 $line_total = max( 0, $line_subtotal - $item_discount );
822 }
823
824 $item->set_props( [
825 'name' => $values->name,
826 'product_id' => $values->product_id ?? $price->get_product_id(),
827 'variation_id' => $values->variation_id ?? 0,
828 'variation' => $values->variation ?? [],
829 'product_type' => $price->get_product_type() ?? '',
830 'shipping_type' => $price->get_shipping_type() ?? '',
831 'digital_auto_complete' => $price->get_digital_auto_complete(),
832 'price_type' => $values->price_type ?? $price->get_price_type(),
833 'price_id' => $price->get_id(),
834 'price_name' => $price->get_name(),
835 'price' => $price->get_price(),
836 'quantity' => absint( $values->quantity ),
837 'tax_class' => $values->tax_class ?? '',
838 'subtotal' => $line_subtotal,
839 'total' => $line_total,
840 'subtotal_tax' => $values->line_subtotal_tax ?? 0,
841 'total_tax' => $values->line_tax ?? 0,
842 'taxes' => $values->line_tax_data ?? [],
843 ] );
844
845 $item->add_meta_data( '_price_settings', $price->get_settings(), true );
846
847 foreach ( $price->get_settings() as $field => $value ) {
848 if ( method_exists( $item, "set_{$field}" ) ) {
849 $item->{"set_{$field}"}( $value );
850 }
851 }
852
853 $item->set_backorder_meta();
854
855 $variation_id = $values->variation_id ?? 0;
856
857 if ( 0 < $variation_id ) {
858 $variation = Helper::get_product_variation( $variation_id );
859 if ( ! $variation ) {
860 throw new StoreEngineException( esc_html__( 'Invalid variation selected!', 'storeengine' ), 'invalid-product-variation' );
861 }
862
863 $item->add_meta_data( '_variation_price', (float) $variation->get_price(), true );
864 $item->set_price( $price->get_price() + (float) $variation->get_price() );
865
866 foreach ( $variation->get_attributes() as $attribute ) {
867 $item->add_meta_data( $attribute->taxonomy, $attribute->slug, true );
868 }
869 }
870
871 if ( 'bundled' === $item->get_product_type() && $price->get_product()->get_bundles() ) {
872 $item->add_meta_data( '_bundles', $price->get_product()->get_bundles(), true );
873 }
874
875 /**
876 * @param OrderItemProduct $item
877 * @param \StoreEngine\Classes\CartItem $values
878 * @param Order $order
879 */
880 do_action( 'storeengine/checkout/create_order_line_item', $item, $values, $order );
881
882 $order->add_item( $item );
883 }
884
885 $order->maybe_set_digital_auto_complete();
886 }
887
888 public static function add_fee( Order &$order, Cart $cart ) {
889 foreach ( $cart->get_fees() as $fee ) {
890 $item = new OrderItemFee();
891 $fee = (object) $fee;
892
893 $item->set_props( [
894 'name' => $fee->name,
895 'tax_class' => $fee->taxable ? $fee->tax_class : '',
896 'amount' => $fee->amount,
897 'total' => $fee->total,
898 'total_tax' => $fee->tax,
899 'taxes' => [ 'total' => $fee->tax_data ],
900 ] );
901
902 /**
903 * Fires after creating fee on Order.
904 *
905 * @param OrderItemFee $item ItemFee object.
906 * @param object $fee Fee data.
907 * @param Order $order Order instance.
908 */
909 do_action( 'storeengine/checkout/create_order_fee_item', $item, $fee, $order );
910
911 $order->add_item( $item );
912 }
913 }
914
915 /**
916 * Materialize subscription signup/setup fees as one-time order fee items.
917 *
918 * Non-subscription setup fees are added to the cart as fees (see Cart::
919 * add_product_to_cart) and flow into the order via add_fee(). Subscription
920 * setup fees deliberately are NOT cart fees — they must not recur, so the
921 * subscription addon folds them into the cart's *aggregate* total via a
922 * price-calculation filter instead. That aggregate is display-only: it never
923 * becomes an order item, so Order::calculate_totals() (which sums line items
924 * + fee items) drops the fee and the recorded order total under-runs what
925 * the customer is charged — e.g. base 400 + fee 100 records as 400 while
926 * gateways collect 500.
927 *
928 * Adding the fee here as a one-time OrderItemFee puts it into the order total
929 * (so order-total gateways like Razorpay/PayPal charge it) and the order
930 * record reconciles with itemizing gateways (Paddle/Stripe). It stays off the
931 * recurring subscription, which is built from the recurring cart (fees
932 * removed), and is skipped on renewals.
933 */
934 public static function add_subscription_setup_fees( Order &$order, Cart $cart ) {
935 if ( $order->get_meta( '_subscription_renewal' ) ) {
936 return;
937 }
938
939 foreach ( $cart->get_cart_items() as $cart_item ) {
940 if ( 'subscription' !== $cart_item->price_type ) {
941 continue;
942 }
943
944 $fee_price = (float) ( $cart_item->setup_fee_price ?? 0 );
945 if ( empty( $cart_item->setup_fee ) || $fee_price <= 0 ) {
946 continue;
947 }
948
949 $item = new OrderItemFee();
950 $item->set_props( [
951 'name' => ! empty( $cart_item->setup_fee_name ) ? $cart_item->setup_fee_name : __( 'Setup Fee', 'storeengine' ),
952 'amount' => $fee_price,
953 'total' => $fee_price,
954 'total_tax' => 0,
955 ] );
956
957 /**
958 * Fires after creating a subscription setup-fee item on the order.
959 *
960 * @param OrderItemFee $item Fee item.
961 * @param object $cart_item Cart item carrying the setup fee.
962 * @param Order $order Order instance.
963 */
964 do_action( 'storeengine/checkout/create_order_setup_fee_item', $item, $cart_item, $order );
965
966 $order->add_item( $item );
967 }
968 }
969
970 public static function add_tax( Order &$order, Cart $cart ) {
971 foreach ( array_keys( $cart->get_cart_contents_taxes() + $cart->get_shipping_taxes() + $cart->get_fee_taxes() ) as $tax_rate_id ) {
972 if ( $tax_rate_id && apply_filters( 'storeengine/frontend/cart/remove_taxes_zero_rate_id', 'zero-rated' ) === $tax_rate_id ) {
973 return;
974 }
975
976 $item = new OrderItemTax();
977 $item->set_props( [
978 'rate_id' => $tax_rate_id,
979 'order_id' => $order->get_id(),
980 'tax_total' => $cart->get_tax_amount( $tax_rate_id ),
981 'shipping_tax_total' => $cart->get_shipping_tax_amount( $tax_rate_id ),
982 'rate_code' => Tax::get_rate_code( $tax_rate_id ),
983 'label' => Tax::get_rate_label( $tax_rate_id ),
984 'compound' => Tax::is_compound( $tax_rate_id ),
985 'rate_percent' => Tax::get_rate_percent_value( $tax_rate_id ),
986 ] );
987
988 /**
989 * Fires after adding tax on Order.
990 *
991 * @param OrderItemTax $item ItemTax object.
992 * @param int $tax_rate_id Tax rate id.
993 * @param Order $order Order instance.
994 */
995 do_action( 'storeengine/checkout/create_order_tax_item', $item, $tax_rate_id, $order );
996
997 $order->add_item( $item );
998 }
999 }
1000
1001 public static function add_shipping( Order &$order, Cart $cart ) {
1002 if ( ! $cart->needs_shipping() ) {
1003 return;
1004 }
1005
1006 $shipping_rates = $cart->get_shipping_methods();
1007 if ( empty( $shipping_rates ) ) {
1008 return;
1009 }
1010
1011 /** @var ShippingRate $shipping_rate */
1012 $shipping_rate = $shipping_rates[0];
1013
1014 $shipping_item = new OrderItemShipping();
1015
1016 $shipping_item->set_props( [
1017 'method_title' => $shipping_rate->get_label(),
1018 'method_id' => $shipping_rate->get_method_id(),
1019 'instance_id' => $shipping_rate->get_instance_id(),
1020 'total' => Formatting::format_decimal( $shipping_rate->get_cost() ),
1021 'taxes' => [ 'total' => $shipping_rate->get_taxes() ],
1022 'tax_status' => $shipping_rate->get_tax_status(),
1023 ] );
1024
1025 foreach ( $shipping_rate->get_meta_data() as $key => $value ) {
1026 $shipping_item->add_meta_data( $key, $value, true );
1027 }
1028
1029 /**
1030 * Fires after adding shipping on Order.
1031 *
1032 * @param OrderItemShipping $shipping_item Shipping item.
1033 * @param ShippingRate $shipping_rate Shipping rate from cart.
1034 * @param Order $order Order instance.
1035 */
1036 do_action( 'storeengine/checkout/create_order_shipping_item', $shipping_item, $shipping_rate, $order );
1037
1038 $order->add_item( $shipping_item );
1039 }
1040
1041 public static function apply_coupon( Order &$order, Cart $cart ) {
1042 foreach ( $cart->get_coupons() as $coupon ) {
1043 $item = new OrderItemCoupon();
1044
1045 $item->set_props( [
1046 'code' => $coupon->get_code(),
1047 'discount' => $cart->get_coupon_discount_amount( $coupon->get_code(), $cart->display_prices_including_tax() ),
1048 'discount_tax' => $cart->get_coupon_discount_tax_amount( $coupon->get_code() ),
1049 ] );
1050
1051 do_action( 'storeengine/checkout/create_order_coupon_item', $item, $coupon, $order );
1052
1053 $order->add_item( $item );
1054 }
1055 }
1056 }
1057