PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
1.10.19 1.10.18 1.10.17 1.10.16 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 All 163 releases
woocommerce-pos / includes / API / V2 / Writers / Order_Writer.php

Order_Writer.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.0, at includes/API/V2/Writers/Order_Writer.php

392 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Order collection writer.
4 *
5 * @package WCPOS\WooCommercePOS\API\V2\Writers
6 */
7
8 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Lifecycle docblocks are intentionally concise.
9
10 namespace WCPOS\WooCommercePOS\API\V2\Writers;
11
12 use WCPOS\WooCommercePOS\Services\Order_Notes;
13 use WCPOS\WooCommercePOS\Services\Pos_Order_Audit;
14 use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
15 use WCPOS\WooCommercePOS\Services\Tax_Id_Writer;
16 use WCPOS\WooCommercePOS\Sync\Meta_Entry;
17 use WCPOS\WooCommercePOS\Sync\Order_Serializer;
18 use WCPOS\WooCommercePOS\Sync\Order_Write_Payload;
19 use WCPOS\WooCommercePOS\Sync\Pos_Uuid;
20 use WP_Error;
21 use WP_REST_Response;
22 use const WCPOS\WooCommercePOS\VERSION;
23
24 /** Owns order audit, tax, reassignment, hook, note, email, and stock behavior. */
25 class Order_Writer extends Null_Writer {
26 /** @var object Mutation store used for HPOS-safe audit persistence. */
27 private $store;
28
29 /** @var Order_Write_Payload Order forward payload shaper. */
30 private Order_Write_Payload $order_payload;
31
32 /** Construct the order writer. */
33 public function __construct( object $store, ?Order_Write_Payload $order_payload = null ) {
34 $this->store = $store;
35 $this->order_payload = $order_payload ?? new Order_Write_Payload();
36 }
37
38 /** Prepare an order create and its create-only hook policy. */
39 public function prepare_create( array $meta, array $payload, callable $validate_tax_ids ) {
40 $created_gmt = $this->validate_client_created_gmt( $payload );
41 if ( is_wp_error( $created_gmt ) ) {
42 return $created_gmt;
43 }
44 $error = $validate_tax_ids( $payload );
45 if ( is_wp_error( $error ) ) {
46 return $error;
47 }
48 $forward = $payload;
49 $forward['created_via'] = 'woocommerce-pos';
50 unset( $forward['tax_ids'] );
51 if ( isset( $forward['meta_data'] ) && is_array( $forward['meta_data'] ) ) {
52 $forward['meta_data'] = $this->without_pos_audit_meta( $forward );
53 }
54 $meta_data = isset( $payload['meta_data'] ) && is_array( $payload['meta_data'] ) ? $payload['meta_data'] : array();
55 $till_meta = Pos_Order_Audit::till_meta_from_payload( $meta_data );
56 $till_meta['_pos_user'] = (string) get_current_user_id();
57 $till_meta['_pos_user_created'] = $till_meta['_pos_user'];
58 return array(
59 'method' => 'POST',
60 'route' => $meta['route'],
61 'payload' => $this->order_payload->for_create( $forward ),
62 'context' => array(
63 'operation' => 'create',
64 'created_gmt' => $created_gmt,
65 'fill_meta' => $till_meta,
66 ),
67 );
68 }
69
70 /** Prepare an order update and its update-only hook/reassignment policy. */
71 public function prepare_update( array $meta, int $id, array $payload, callable $validate_tax_ids ) {
72 $error = $validate_tax_ids( $payload );
73 if ( is_wp_error( $error ) ) {
74 return $error;
75 }
76 return array(
77 'method' => 'PUT',
78 'route' => $meta['route'] . '/' . $id,
79 'payload' => $payload,
80 'context' => array(),
81 'context_factory' => function () use ( $id, $payload ) {
82 return $this->prepare_order_update_after_read( $id, $payload );
83 },
84 );
85 }
86
87 /** Repair an existing born-twice order without inventing a version stamp. */
88 public function validate_existing_create( int $id, array $payload, array $prepared ) {
89 $this->stamp_order_audit( $id, $payload, false );
90 return null;
91 }
92
93 /** Forward within the named order hook lifecycle. */
94 public function forward( array $prepared, callable $forward ) {
95 return $this->forward_with_order_lifecycle( $prepared, $forward );
96 }
97
98 /** Persist the order behavior assigned to a controller-owned protocol phase. */
99 public function persist( string $phase, int $id, array $payload, array $current = array(), array $response_data = array(), array $context = array() ): void {
100 if ( 'create_before_identity' === $phase ) {
101 $this->persist_tax_ids( $id, $payload, true );
102 } elseif ( 'create_after_identity' === $phase ) {
103 $this->stamp_order_audit( $id, $payload, true );
104 $order = wc_get_order( $id );
105 if ( $order ) {
106 Order_Notes::add_creation_note( $order, get_current_user_id(), $order->get_meta( '_pos_store' ) );
107 }
108 } elseif ( 'create_recovery' === $phase ) {
109 $this->stamp_order_audit( $id, $payload, false );
110 $this->persist_tax_ids( $id, $payload, true );
111 } elseif ( 'update' === $phase ) {
112 $this->stamp_order_till_meta( $id, $payload );
113 $this->persist_tax_ids( $id, $payload, false );
114 $this->persist_cashier_store_reassignment( $id, $current, $response_data, $context );
115 if ( ! empty( $context['clear_email'] ) ) {
116 $order = wc_get_order( $id );
117 if ( $order ) {
118 $order->set_billing_email( '' );
119 $order->get_data_store()->update( $order );
120 }
121 }
122 }
123 }
124
125 /** Execute delete with the named stock restore/rollback lifecycle. */
126 public function delete( array $meta, int $id, array $mutation, callable $dispatch, callable $can_delete ) {
127 $force = (bool) ( $mutation['force'] ?? false );
128 return $this->forward_with_stock_restore_rollback(
129 $this->delete_request( $meta['route'] . '/' . $id, $id, $force ),
130 $id,
131 $force,
132 $dispatch,
133 $can_delete
134 );
135 }
136
137 /** Read an order with six-decimal precision and POS links. */
138 public function document( array $meta, int $id, callable $default_document ) {
139 $response = $default_document( $meta, $id, array( 'dp' => '6' ) );
140 $data = $response->get_data();
141 $order = wc_get_order( $id );
142 if ( is_array( $data ) && $order ) {
143 $response->set_data( Order_Serializer::add_pos_links( $data, $order ) );
144 }
145 return $response;
146 }
147
148 /** Build the canonical augmented order response document. */
149 public function build_response_document( array $bare, string $record_id, array $meta, int $id, callable $default_builder ): array {
150 $order = wc_get_order( $id );
151 if ( $order ) {
152 $bare = ( new Order_Serializer() )->document( $bare, Order_Serializer::V2_AUGMENTATIONS, null, $order );
153 }
154 return Pos_Uuid::ensure_in_payload( $bare, $record_id );
155 }
156
157 /** Apply create/update hook policies around one exact forwarded order. */
158 private function forward_with_order_lifecycle( array $prepared, callable $forward ) {
159 $context = $prepared['context'];
160 $forwarded_order = null;
161 $pre_insert = static function ( $order, $request, $creating ) use ( $context, &$forwarded_order ) {
162 $is_create = 'create' === $context['operation'];
163 if ( $is_create && $creating && $order instanceof \WC_Order && null === $forwarded_order ) {
164 $forwarded_order = $order;
165 }
166 $target = $is_create ? ( $creating && $order === $forwarded_order ) : ( $order instanceof \WC_Order && $context['id'] === $order->get_id() );
167 if ( $target ) {
168 foreach ( $context['fill_meta'] as $key => $value ) {
169 $order->update_meta_data( $key, $value );
170 }
171 }
172 if ( $is_create && $creating && null !== $context['created_gmt'] && $order instanceof \WC_Order ) {
173 $order->set_date_created( $context['created_gmt'] );
174 }
175 return $order;
176 };
177 $created_via = static function ( $order ) use ( &$forwarded_order ) {
178 if ( $order instanceof \WC_Order && $order === $forwarded_order && 'woocommerce-pos' !== $order->get_created_via() ) {
179 $order->set_created_via( 'woocommerce-pos' );
180 }
181 };
182 $use_filter = 'create' === $context['operation'] || array() !== $context['fill_meta'];
183 if ( $use_filter ) {
184 add_filter( 'woocommerce_rest_pre_insert_shop_order_object', $pre_insert, 10, 3 );
185 }
186 if ( 'create' === $context['operation'] ) {
187 add_action( 'woocommerce_before_order_object_save', $created_via );
188 }
189 try {
190 return $forward( $prepared['method'], $prepared['route'], $prepared['payload'] );
191 } finally {
192 if ( $use_filter ) {
193 remove_filter( 'woocommerce_rest_pre_insert_shop_order_object', $pre_insert, 10 );
194 }
195 if ( 'create' === $context['operation'] ) {
196 remove_action( 'woocommerce_before_order_object_save', $created_via );
197 }
198 }
199 }
200
201 /** Name and execute the permanent/trash stock restore/rollback protocol. */
202 private function forward_with_stock_restore_rollback( $request, int $id, bool $force, callable $dispatch, callable $can_delete ) {
203 $setting = SettingsService::instance()->restore_stock_on_delete_enabled();
204 $restore_stock = apply_filters( 'woocommerce_pos_restore_stock_on_delete', $setting, $id );
205 $pre_restored = false;
206 if ( $restore_stock && $force && $this->order_stock_reduced( $id ) && $can_delete( $id ) ) {
207 wc_maybe_increase_stock_levels( $id );
208 $pre_restored = true;
209 }
210 $response = $dispatch( $request );
211 if ( $response->get_status() >= 400 ) {
212 if ( $pre_restored ) {
213 wc_maybe_reduce_stock_levels( $id );
214 }
215 return new WP_REST_Response( $response->get_data(), $response->get_status() );
216 }
217 if ( $restore_stock && ! $force ) {
218 wc_maybe_increase_stock_levels( $id );
219 }
220 return $response;
221 }
222
223 /** Capture and authorize the cashier/store reassignment lifecycle. */
224 private function prepare_order_update_after_read( int $id, array $payload ): array {
225 $reassignment = array();
226 foreach ( is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array() as $entry ) {
227 $key = Meta_Entry::key( $entry );
228 if ( is_scalar( $key ) && in_array( (string) $key, array( '_pos_user', '_pos_store' ), true ) ) {
229 $reassignment[ (string) $key ] = Meta_Entry::value( $entry ) ?? '';
230 }
231 }
232 $authorized = isset( $reassignment['_pos_store'] ) && is_scalar( $reassignment['_pos_store'] ) && '' !== (string) $reassignment['_pos_store']
233 ? (bool) apply_filters( 'woocommerce_pos_order_store_reassignment_allowed', true, (string) $reassignment['_pos_store'], $id )
234 : false;
235 $forward = $payload;
236 unset( $forward['created_via'], $forward['tax_ids'] );
237 if ( isset( $forward['meta_data'] ) && is_array( $forward['meta_data'] ) ) {
238 $forward['meta_data'] = $this->without_pos_audit_meta( $forward, $id );
239 }
240 $clear_email = isset( $forward['billing'] ) && is_array( $forward['billing'] )
241 && array_key_exists( 'email', $forward['billing'] ) && '' === $forward['billing']['email'];
242 $fill_meta = array();
243 $pre_store = null;
244 $order = wc_get_order( $id );
245 if ( $order ) {
246 $pre_store = (string) $order->get_meta( '_pos_store' );
247 foreach ( array( '_pos_user', '_pos_user_created' ) as $key ) {
248 if ( '' === (string) $order->get_meta( $key ) ) {
249 $fill_meta[ $key ] = (string) get_current_user_id();
250 }
251 }
252 $till = Pos_Order_Audit::till_meta_from_payload( is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array() );
253 foreach ( Pos_Order_Audit::cash_meta_keys() as $key ) {
254 if ( isset( $till[ $key ] ) && '' === (string) $order->get_meta( $key ) ) {
255 $fill_meta[ $key ] = $till[ $key ];
256 }
257 }
258 if ( $authorized && (string) $order->get_meta( '_pos_store' ) !== (string) $reassignment['_pos_store'] ) {
259 $fill_meta['_pos_store'] = (string) $reassignment['_pos_store'];
260 }
261 }
262 return array(
263 'payload' => $this->order_payload->for_update( $id, $forward ),
264 'context' => array(
265 'operation' => 'update',
266 'id' => $id,
267 'clear_email' => $clear_email,
268 'reassignment' => $reassignment,
269 'store_authorized' => $authorized,
270 'pre_store' => $pre_store,
271 'fill_meta' => $fill_meta,
272 ),
273 );
274 }
275
276 /** Persist reassignment and its mutually exclusive order-note policy. */
277 private function persist_cashier_store_reassignment( int $id, array $current, array $data, array $context ): void {
278 $order = wc_get_order( $id );
279 if ( ! $order || ! is_array( $data ) ) {
280 return;
281 }
282 $current_user = get_current_user_id();
283 $change = $context['reassignment'];
284 $old_user = $order->get_meta( '_pos_user' );
285 $old_store = null !== $context['pre_store'] ? $context['pre_store'] : $order->get_meta( '_pos_store' );
286 $cashier_changed = isset( $change['_pos_user'] ) && is_numeric( $change['_pos_user'] )
287 && (int) $change['_pos_user'] === $current_user && (string) $old_user !== (string) $current_user;
288 $store_changed = $context['store_authorized'] && (string) $old_store !== (string) $change['_pos_store'];
289 if ( $cashier_changed ) {
290 $order->update_meta_data( '_pos_user', (string) $current_user );
291 }
292 if ( $store_changed ) {
293 $order->update_meta_data( '_pos_store', (string) $change['_pos_store'] );
294 }
295 if ( $cashier_changed || $store_changed ) {
296 $order->save();
297 }
298 if ( 'pos-open' === ( $data['status'] ?? '' ) && 'pos-open' !== ( $current['status'] ?? '' ) ) {
299 Order_Notes::add_reopen_note( $order, $current_user, $order->get_meta( '_pos_store' ) );
300 } else {
301 if ( $cashier_changed ) {
302 Order_Notes::add_cashier_change_note( $order, $old_user, $current_user );
303 }
304 if ( $store_changed ) {
305 Order_Notes::add_store_change_note( $order, $old_store, $change['_pos_store'] );
306 }
307 }
308 if ( array_key_exists( 'customer_id', $current ) && array_key_exists( 'customer_id', $data ) && (int) $current['customer_id'] !== (int) $data['customer_id'] ) {
309 Order_Notes::add_pos_customer_change_note( $order, $current['customer_id'], $data['customer_id'] );
310 }
311 }
312
313 /** Persist order tax IDs or the create-time customer snapshot. */
314 private function persist_tax_ids( int $id, array $payload, bool $is_create ): void {
315 $order = wc_get_order( $id );
316 if ( ! $order ) {
317 return;
318 }
319 if ( is_array( $payload['tax_ids'] ?? null ) ) {
320 ( new Tax_Id_Writer() )->write_for_order( $order, $payload['tax_ids'] );
321 } elseif ( $is_create && $order->get_customer_id() > 0 ) {
322 ( new Tax_Id_Writer() )->snapshot_from_user_to_order( $order, $order->get_customer_id() );
323 }
324 }
325
326 /** Persist server-owned order audit metadata. */
327 private function stamp_order_audit( int $id, array $payload, bool $stamp_version ): void {
328 $meta = array( '_pos_user' => (string) get_current_user_id() );
329 if ( $stamp_version ) {
330 $meta['_woocommerce_pos_version'] = VERSION;
331 $meta['_pos_user_created'] = $meta['_pos_user'];
332 }
333 $payload_meta = is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array();
334 $this->store->persist_order_audit_meta( $id, array_merge( $meta, Pos_Order_Audit::till_meta_from_payload( $payload_meta ) ), 'woocommerce-pos' );
335 }
336
337 /** Persist missing cash-tender metadata after update. */
338 private function stamp_order_till_meta( int $id, array $payload ): void {
339 $meta = array();
340 foreach ( is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array() as $entry ) {
341 $key = Meta_Entry::key( $entry );
342 $value = Meta_Entry::value( $entry ) ?? '';
343 if ( is_scalar( $key ) && in_array( (string) $key, Pos_Order_Audit::cash_meta_keys(), true ) && is_scalar( $value ) && '' !== (string) $value ) {
344 $meta[ (string) $key ] = (string) $value;
345 }
346 }
347 if ( $meta ) {
348 $this->store->persist_order_audit_meta( $id, $meta );
349 }
350 }
351
352 /** Strip server-owned audit metadata from a forward. */
353 private function without_pos_audit_meta( array $payload, int $id = 0 ): array {
354 $meta = is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array();
355 $protected = $id > 0 ? Pos_Order_Audit::audit_meta_ids( wc_get_order( $id ) ) : array();
356 return Pos_Order_Audit::strip_audit_meta( $meta, $protected );
357 }
358
359 /** Validate and normalize the optional client create timestamp. */
360 private function validate_client_created_gmt( array $payload ) {
361 if ( ! isset( $payload['date_created_gmt'] ) ) {
362 return null;
363 }
364 if ( ! is_scalar( $payload['date_created_gmt'] ) ) {
365 return $this->invalid_created_gmt();
366 }
367 $value = wc_clean( wp_unslash( (string) $payload['date_created_gmt'] ) );
368 if ( '' === $value ) {
369 return null;
370 }
371 $timestamp = 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/i', $value )
372 ? rest_parse_date( 'Z' === strtoupper( substr( $value, -1 ) ) ? $value : $value . 'Z', true ) : false;
373 if ( false === $timestamp ) {
374 return $this->invalid_created_gmt();
375 }
376 return $timestamp > time() + DAY_IN_SECONDS
377 ? new WP_Error( 'woocommerce_pos_rest_future_date_created_gmt', __( 'date_created_gmt cannot be more than 24 hours in the future.', 'woocommerce-pos' ), array( 'status' => 400 ) )
378 : $timestamp;
379 }
380
381 /** Build the stable invalid create timestamp error. */
382 private function invalid_created_gmt(): WP_Error {
383 return new WP_Error( 'woocommerce_pos_rest_invalid_date_created_gmt', __( 'date_created_gmt must be a valid ISO 8601 UTC date.', 'woocommerce-pos' ), array( 'status' => 400 ) );
384 }
385
386 /** Whether order stock was actually reduced before delete. */
387 private function order_stock_reduced( int $id ): bool {
388 $order = wc_get_order( $id );
389 return $order instanceof \WC_Order && (bool) $order->get_data_store()->get_stock_reduced( $id );
390 }
391 }
392