PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 All 159 releases
woocommerce-pos / includes / API / V1 / Checkout_Controller.php

Checkout_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/API/V1/Checkout_Controller.php

365 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * POS checkout controller.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V1;
9
10 \defined( 'ABSPATH' ) || die;
11
12 use WC_Payment_Gateway;
13 use WC_Order;
14 use WC_REST_Controller;
15 use WCPOS\WooCommercePOS\Payments\Checkout_State_Repository;
16 use WCPOS\WooCommercePOS\Payments\Gateway_Contract;
17 use WCPOS\WooCommercePOS\Payments\Idempotency_Repository;
18 use WCPOS\WooCommercePOS\Services\Stock_Validator;
19 use WP_Error;
20 use WP_REST_Request;
21 use WP_REST_Response;
22 use WP_REST_Server;
23
24 /**
25 * POS checkout controller.
26 */
27 class Checkout_Controller extends WC_REST_Controller {
28 /**
29 * REST namespace.
30 *
31 * @var string
32 */
33 protected $namespace = 'wcpos/v1';
34
35 /**
36 * REST base.
37 *
38 * @var string
39 */
40 protected $rest_base = 'orders';
41
42 /**
43 * Checkout state repository.
44 *
45 * @var Checkout_State_Repository
46 */
47 private $state_repository;
48
49 /**
50 * Idempotency repository.
51 *
52 * @var Idempotency_Repository
53 */
54 private $idempotency_repository;
55
56 /**
57 * Shared gateway contract helper.
58 *
59 * @var Gateway_Contract
60 */
61 private $gateway_contract;
62
63 /**
64 * Constructor.
65 */
66 public function __construct() {
67 $this->state_repository = new Checkout_State_Repository();
68 $this->idempotency_repository = new Idempotency_Repository();
69 $this->gateway_contract = new Gateway_Contract();
70 }
71
72 /**
73 * Register routes.
74 */
75 public function register_routes(): void {
76 register_rest_route(
77 $this->namespace,
78 '/' . $this->rest_base . '/(?P<id>[\d]+)/checkout',
79 array(
80 array(
81 'methods' => WP_REST_Server::CREATABLE,
82 'callback' => array( $this, 'create_item' ),
83 'permission_callback' => array( $this, 'create_item_permissions_check' ),
84 ),
85 array(
86 'methods' => WP_REST_Server::READABLE,
87 'callback' => array( $this, 'get_item' ),
88 'permission_callback' => array( $this, 'get_item_permissions_check' ),
89 ),
90 )
91 );
92 }
93
94 /**
95 * Read permissions check.
96 *
97 * @param WP_REST_Request $_request Request object.
98 */
99 public function get_item_permissions_check( $_request ) {
100 return current_user_can( 'publish_shop_orders' )
101 ? true
102 : new WP_Error( 'rest_forbidden', __( 'Sorry, you cannot view checkout state.', 'woocommerce-pos' ), array( 'status' => rest_authorization_required_code() ) );
103 }
104
105 /**
106 * Create permissions check.
107 *
108 * @param WP_REST_Request $_request Request object.
109 */
110 public function create_item_permissions_check( $_request ) {
111 return current_user_can( 'publish_shop_orders' )
112 ? true
113 : new WP_Error( 'rest_forbidden', __( 'Sorry, you cannot process checkout.', 'woocommerce-pos' ), array( 'status' => rest_authorization_required_code() ) );
114 }
115
116 /**
117 * Create a checkout state mutation.
118 *
119 * @param WP_REST_Request $request Request object.
120 * @throws \Throwable When gateway processing fails.
121 */
122 public function create_item( $request ) {
123 $order = $this->get_order( (int) $request['id'] );
124 if ( is_wp_error( $order ) ) {
125 return $order;
126 }
127
128 $params = $request->get_json_params();
129 if ( empty( $params ) ) {
130 $params = $request->get_body_params();
131 }
132 if ( empty( $params ) ) {
133 $params = $request->get_params();
134 }
135
136 $idempotency_key = (string) $request->get_header( 'X-WCPOS-Idempotency-Key' );
137 if ( empty( $idempotency_key ) ) {
138 return new WP_Error(
139 'wcpos_missing_idempotency_key',
140 /* translators: REST API schema field label or error message. */
141 __( 'Missing X-WCPOS-Idempotency-Key header.', 'woocommerce-pos' ),
142 array( 'status' => 400 )
143 );
144 }
145
146 $gateway_id = isset( $params['gateway_id'] ) ? (string) $params['gateway_id'] : '';
147 $gateway = $this->get_gateway( $gateway_id );
148 if ( ! $gateway ) {
149 return new WP_Error(
150 'wcpos_payment_gateway_not_found',
151 /* translators: REST API schema field label or error message. */
152 __( 'Payment gateway not found.', 'woocommerce-pos' ),
153 array( 'status' => 404 )
154 );
155 }
156
157 if ( ! $this->gateway_contract->is_pos_enabled( $gateway ) || ! $this->gateway_contract->supports_checkout( $gateway, $request ) ) {
158 return new WP_Error(
159 'wcpos_payment_gateway_not_available',
160 __( 'Payment gateway is not available for POS checkout.', 'woocommerce-pos' ),
161 array( 'status' => 400 )
162 );
163 }
164
165 $idempotency_scope = $this->get_idempotency_scope( $order->get_id() );
166 $request_hash = md5(
167 wp_json_encode(
168 $this->normalize_for_hash(
169 array(
170 'order_id' => $order->get_id(),
171 'params' => $params,
172 )
173 )
174 )
175 );
176 $claim = $this->idempotency_repository->claim( $idempotency_scope, $idempotency_key, $request_hash );
177
178 if ( is_wp_error( $claim ) ) {
179 return $claim;
180 }
181
182 if ( is_array( $claim ) ) {
183 return new WP_REST_Response( $claim['body'], $claim['status_code'] );
184 }
185
186 try {
187 $action = isset( $params['action'] ) ? (string) $params['action'] : 'start';
188 $payment_data = isset( $params['payment_data'] ) && is_array( $params['payment_data'] ) ? $params['payment_data'] : array();
189 // Validate on EVERY action, not just `start`. The action string is
190 // free-form and dispatched to a gateway filter, and the shipped surface
191 // already carries `update` alongside `start`, so a gateway completing
192 // payment on a later action would otherwise take money for stock that
193 // was never checked. validate_checkout() short-circuits when the order
194 // already holds a sufficient reservation, so this costs a lookup rather
195 // than a second hold.
196 $validation = Stock_Validator::instance()->validate_checkout( $order );
197 if ( is_wp_error( $validation ) ) {
198 Stock_Validator::instance()->release_checkout_stock( $order );
199
200 return $validation;
201 }
202 try {
203 $state = $this->dispatch_checkout_action( $gateway, $order->get_id(), $action, $payment_data, $order, $request );
204 } catch ( \Throwable $exception ) {
205 // Every action can now be holding stock, so every action gives it
206 // back when dispatch fails; the normalized cancelled/failed branch
207 // below is never reached on these paths.
208 Stock_Validator::instance()->release_checkout_stock( $order );
209
210 throw $exception;
211 }
212
213 if ( is_wp_error( $state ) ) {
214 Stock_Validator::instance()->release_checkout_stock( $order );
215
216 return $state;
217 }
218
219 $state = $this->normalize_state( $order->get_id(), $gateway_id, $state );
220 if ( \in_array( $state['status'], array( 'cancelled', 'failed' ), true ) ) {
221 Stock_Validator::instance()->release_checkout_stock( $order );
222 }
223 $this->state_repository->upsert( $order->get_id(), $state );
224
225 if ( 'completed' === $state['status'] ) {
226 $order->update_meta_data( '_pos_checkout_gateway_id', $gateway_id );
227 $order->update_meta_data( '_pos_checkout_idempotency_key', $idempotency_key );
228 $order->save_meta_data();
229 }
230
231 $this->idempotency_repository->store( $idempotency_scope, $idempotency_key, $request_hash, 200, $state );
232
233 return rest_ensure_response( $state );
234 } finally {
235 $this->idempotency_repository->release( $idempotency_scope, $idempotency_key );
236 }
237 }
238
239 /**
240 * Return the last known checkout state.
241 *
242 * @param WP_REST_Request $request Request object.
243 */
244 public function get_item( $request ) {
245 $order = $this->get_order( (int) $request['id'] );
246 if ( is_wp_error( $order ) ) {
247 return $order;
248 }
249
250 $state = $this->state_repository->get( $order->get_id() );
251 if ( empty( $state ) ) {
252 $gateway_id = $order->get_meta( '_pos_checkout_gateway_id', true );
253
254 $state = array(
255 'checkout_id' => null,
256 'order_id' => $order->get_id(),
257 'gateway_id' => $gateway_id ? $gateway_id : '',
258 'status' => 'pending',
259 'provider_data' => array(),
260 'terminal' => false,
261 );
262 }
263
264 return rest_ensure_response( $state );
265 }
266
267 /**
268 * Get an order by ID.
269 *
270 * @param int $order_id Order ID.
271 */
272 private function get_order( int $order_id ) {
273 $order = wc_get_order( $order_id );
274
275 if ( ! $order ) {
276 return new WP_Error(
277 'wcpos_order_not_found',
278 /* translators: REST API schema field label or error message. */
279 __( 'Order not found.', 'woocommerce-pos' ),
280 array( 'status' => 404 )
281 );
282 }
283
284 return $order;
285 }
286
287 /**
288 * Get a payment gateway by ID.
289 *
290 * @param string $gateway_id Gateway ID.
291 */
292 private function get_gateway( string $gateway_id ): ?WC_Payment_Gateway {
293 WC()->payment_gateways();
294 $gateways = WC()->payment_gateways->payment_gateways();
295
296 return $gateways[ $gateway_id ] ?? null;
297 }
298
299 /**
300 * Dispatch checkout processing to the resolved gateway only.
301 *
302 * @param WC_Payment_Gateway $gateway Gateway object.
303 * @param int $order_id Order ID.
304 * @param string $action Checkout action.
305 * @param array $payment_data Payment data.
306 * @param WC_Order $order Order object.
307 * @param WP_REST_Request $request Request object.
308 *
309 * @return array|WP_Error
310 */
311 private function dispatch_checkout_action( WC_Payment_Gateway $gateway, int $order_id, string $action, array $payment_data, WC_Order $order, WP_REST_Request $request ) {
312 return $this->gateway_contract->process_checkout_action( $gateway, $order_id, $action, $payment_data, $order, $request );
313 }
314
315 /**
316 * Build order-scoped idempotency namespace.
317 *
318 * @param int $order_id Order ID.
319 */
320 private function get_idempotency_scope( int $order_id ): string {
321 return 'checkout:' . $order_id;
322 }
323
324 /**
325 * Normalize checkout state payload.
326 *
327 * @param int $order_id Order ID.
328 * @param string $gateway_id Gateway ID.
329 * @param array $state Raw state.
330 */
331 private function normalize_state( int $order_id, string $gateway_id, array $state ): array {
332 $status = (string) ( $state['status'] ?? 'processing' );
333
334 return array(
335 'checkout_id' => $state['checkout_id'] ?? null,
336 'order_id' => $order_id,
337 'gateway_id' => $state['gateway_id'] ?? $gateway_id,
338 'status' => $status,
339 'provider_data' => isset( $state['provider_data'] ) && is_array( $state['provider_data'] ) ? $state['provider_data'] : array(),
340 'terminal' => ( isset( $state['terminal'] ) ? (bool) $state['terminal'] : false ) || $this->gateway_contract->is_terminal_status( $status ),
341 );
342 }
343
344 /**
345 * Normalize request data for idempotency hashing.
346 *
347 * @param mixed $value Raw value.
348 *
349 * @return mixed
350 */
351 private function normalize_for_hash( $value ) {
352 if ( ! is_array( $value ) ) {
353 return $value;
354 }
355
356 ksort( $value );
357
358 foreach ( $value as $key => $nested ) {
359 $value[ $key ] = $this->normalize_for_hash( $nested );
360 }
361
362 return $value;
363 }
364 }
365