| @@ -14,8 +14,9 @@ | ||
| 14 | 14 | use WC_REST_Controller; |
| 15 | 15 | use WCPOS\WooCommercePOS\Payments\Checkout_State_Repository; |
| 16 | 16 | use WCPOS\WooCommercePOS\Payments\Gateway_Contract; |
| 17 | 17 | use WCPOS\WooCommercePOS\Payments\Idempotency_Repository; |
| 18 | +use WCPOS\WooCommercePOS\Services\Stock_Validator; | |
| 18 | 19 | use WP_Error; |
| 19 | 20 | use WP_REST_Request; |
| 20 | 21 | use WP_REST_Response; |
| 21 | 22 | use WP_REST_Server; |
| @@ -115,8 +116,9 @@ | ||
| 115 | 116 | /** |
| 116 | 117 | * Create a checkout state mutation. |
| 117 | 118 | * |
| 118 | 119 | * @param WP_REST_Request $request Request object. |
| 120 | + * @throws \Throwable When gateway processing fails. | |
| 119 | 121 | */ |
| 120 | 122 | public function create_item( $request ) { |
| 121 | 123 | $order = $this->get_order( (int) $request['id'] ); |
| 122 | 124 | if ( is_wp_error( $order ) ) { |
| @@ -183,15 +185,42 @@ | ||
| 183 | 185 | |
| 184 | 186 | try { |
| 185 | 187 | $action = isset( $params['action'] ) ? (string) $params['action'] : 'start'; |
| 186 | 188 | $payment_data = isset( $params['payment_data'] ) && is_array( $params['payment_data'] ) ? $params['payment_data'] : array(); |
| 187 | - $state = $this->dispatch_checkout_action( $gateway, $order->get_id(), $action, $payment_data, $order, $request ); | |
| 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 ); | |
| 188 | 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 | + | |
| 189 | 213 | if ( is_wp_error( $state ) ) { |
| 214 | + Stock_Validator::instance()->release_checkout_stock( $order ); | |
| 215 | + | |
| 190 | 216 | return $state; |
| 191 | 217 | } |
| 192 | 218 | |
| 193 | 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 | + } | |
| 194 | 223 | $this->state_repository->upsert( $order->get_id(), $state ); |
| 195 | 224 | |
| 196 | 225 | if ( 'completed' === $state['status'] ) { |
| 197 | 226 | $order->update_meta_data( '_pos_checkout_gateway_id', $gateway_id ); |