PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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.18, at includes/API/V2/Writers/Order_Writer.php

442 lines 19.0 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\Order_Write_Intent;
14 use WCPOS\WooCommercePOS\Services\Pos_Order_Audit;
15 use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
16 use WCPOS\WooCommercePOS\Services\Stock_Validator;
17 use WCPOS\WooCommercePOS\Sync\Meta_Entry;
18 use WCPOS\WooCommercePOS\Sync\Order_Serializer;
19 use WCPOS\WooCommercePOS\Sync\Order_Write_Payload;
20 use WCPOS\WooCommercePOS\Sync\Pos_Uuid;
21 use WP_Error;
22 use WP_REST_Response;
23 use const WCPOS\WooCommercePOS\VERSION;
24
25 /** Owns order audit, tax, reassignment, hook, note, email, and stock behavior. */
26 class Order_Writer extends Null_Writer {
27 /**
28 * Audit meta recording HOW an order came to be paid (ADR 0035 path 3):
29 * `offline` means the till ASSERTED payment via `set_paid` — no gateway ran.
30 * Server-owned (spoof-stripped via Pos_Order_Audit::SERVER_META_KEYS).
31 */
32 public const PAYMENT_ASSERTED_META = '_pos_payment_asserted';
33 public const PAYMENT_ASSERTED_OFFLINE = 'offline';
34
35 /** @var object Mutation store used for HPOS-safe audit persistence. */
36 private $store;
37
38 /** @var Order_Write_Payload Order forward payload shaper. */
39 private Order_Write_Payload $order_payload;
40
41 /** Construct the order writer. */
42 public function __construct( object $store, ?Order_Write_Payload $order_payload = null ) {
43 $this->store = $store;
44 $this->order_payload = $order_payload ?? new Order_Write_Payload();
45 }
46
47 /** Prepare an order create and its create-only hook policy. */
48 public function prepare_create( array $meta, array $payload, callable $validate_tax_ids ) {
49 $created_gmt = $this->order_payload->validate_client_created_gmt( $payload );
50 if ( is_wp_error( $created_gmt ) ) {
51 return $created_gmt;
52 }
53 $error = $validate_tax_ids( $payload );
54 if ( is_wp_error( $error ) ) {
55 return $error;
56 }
57 $forward = $payload;
58 $forward['created_via'] = 'woocommerce-pos';
59 unset( $forward['tax_ids'] );
60 if ( isset( $forward['meta_data'] ) && is_array( $forward['meta_data'] ) ) {
61 $forward['meta_data'] = $this->without_pos_audit_meta( $forward );
62 }
63 $meta_data = isset( $payload['meta_data'] ) && is_array( $payload['meta_data'] ) ? $payload['meta_data'] : array();
64 $till_meta = Pos_Order_Audit::till_meta_from_payload( $meta_data );
65 $till_meta['_pos_user'] = (string) get_current_user_id();
66 $till_meta['_pos_user_created'] = $till_meta['_pos_user'];
67 if ( self::asserts_payment( $payload ) ) {
68 // A CREATE carrying set_paid IS the payment event — the push itself
69 // marks the fresh order paid with no gateway involved.
70 $till_meta[ self::PAYMENT_ASSERTED_META ] = self::PAYMENT_ASSERTED_OFFLINE;
71 }
72 return array(
73 'method' => 'POST',
74 'route' => $meta['route'],
75 'payload' => $this->order_payload->for_create( $forward ),
76 'context' => array(
77 'operation' => 'create',
78 'created_gmt' => $created_gmt,
79 'fill_meta' => $till_meta,
80 ),
81 );
82 }
83
84 /** Prepare an order update and its update-only hook/reassignment policy. */
85 public function prepare_update( array $meta, int $id, array $payload, callable $validate_tax_ids ) {
86 $error = $validate_tax_ids( $payload );
87 if ( is_wp_error( $error ) ) {
88 return $error;
89 }
90 return array(
91 'method' => 'PUT',
92 'route' => $meta['route'] . '/' . $id,
93 'payload' => $payload,
94 'context' => array(),
95 'context_factory' => function () use ( $id, $payload ) {
96 return $this->prepare_order_update_after_read( $id, $payload );
97 },
98 );
99 }
100
101 /** Whether the push payload asserts payment via wc/v3's write-only set_paid flag. */
102 private static function asserts_payment( array $payload ): bool {
103 return isset( $payload['set_paid'] ) && rest_sanitize_boolean( $payload['set_paid'] );
104 }
105
106 /** Repair an existing born-twice order without inventing a version stamp. */
107 public function validate_existing_create( int $id, array $payload, array $prepared ) {
108 // The repair runs against an EXISTING order: if a gateway paid it between
109 // the two create arrivals, the replayed set_paid no longer describes this
110 // order's payment — same needs_payment() guard as the update path. The
111 // recovery phases keep stamping unconditionally: there the order was paid
112 // by this very push.
113 $order = wc_get_order( $id );
114 if ( $order && ! $order->needs_payment() && '' === (string) $order->get_meta( self::PAYMENT_ASSERTED_META ) ) {
115 unset( $payload['set_paid'] );
116 }
117 $this->stamp_order_audit( $id, $payload, false );
118 return null;
119 }
120
121 /** Forward within the named order hook lifecycle. */
122 public function forward( array $prepared, callable $forward ) {
123 $declared = $prepared['context'];
124 if ( ! in_array( $declared['operation'] ?? '', array( 'create', 'update' ), true ) ) {
125 return $this->forward_with_reserved_stock( $prepared, $forward );
126 }
127 $payload = $prepared['payload'];
128 $declared['requested_status'] = isset( $payload['status'] ) ? (string) $payload['status'] : '';
129 $declared['set_paid'] = isset( $payload['set_paid'] ) && rest_sanitize_boolean( $payload['set_paid'] );
130 return Order_Write_Intent::open(
131 $declared,
132 function () use ( $prepared, $forward ) {
133 return $this->forward_with_reserved_stock( $prepared, $forward );
134 }
135 );
136 }
137
138 /**
139 * Wrap the create forward in the shared create-pending -> reserve -> complete
140 * sequence, so this lane gets the SAME anti-overselling guarantee as wcpos/v1.
141 *
142 * Without this the only stock check on this lane is the `pre_insert` filter,
143 * which runs against an unsaved order (id 0) and so can only compare
144 * availability — it cannot take a reservation, and two terminals selling the
145 * last unit concurrently both pass it. See Stock_Validator::around_paid_create().
146 *
147 * @param array $prepared Prepared forward.
148 * @param callable $forward Underlying wc/v3 dispatch.
149 */
150 private function forward_with_reserved_stock( array $prepared, callable $forward ) {
151 $context = $prepared['context'];
152 $payload = $prepared['payload'];
153 $paid = isset( $payload['set_paid'] ) && rest_sanitize_boolean( $payload['set_paid'] );
154 $status = isset( $payload['status'] ) ? (string) $payload['status'] : '';
155 $validator = Stock_Validator::instance();
156
157 if ( 'create' !== $context['operation']
158 || ! SettingsService::instance()->prevent_overselling_enabled()
159 || ! $validator->should_validate_create_payload( $status, $paid ) ) {
160 return $this->forward_with_order_lifecycle( $prepared, $forward );
161 }
162
163 $response = null;
164 $order = $validator->around_paid_create(
165 array(
166 'status' => $status,
167 'set_paid' => $paid,
168 'transaction_id' => isset( $payload['transaction_id'] ) ? (string) $payload['transaction_id'] : '',
169 ),
170 function ( array $neutralised ) use ( $prepared, $forward, &$response ) {
171 $prepared['payload']['status'] = $neutralised['status'];
172 $prepared['payload']['set_paid'] = $neutralised['set_paid'];
173 $response = $this->forward_with_order_lifecycle( $prepared, $forward );
174 $data = $response instanceof \WP_REST_Response ? $response->get_data() : null;
175 $id = is_array( $data ) && isset( $data['id'] ) ? (int) $data['id'] : 0;
176
177 return $id > 0 ? wc_get_order( $id ) : $response;
178 }
179 );
180
181 if ( is_wp_error( $order ) ) {
182 return $order;
183 }
184
185 // The controller rebuilds its response document from wc_get_order( $id )
186 // (see document()/build_response_document()), so the forwarded body does
187 // not need re-shaping after payment completes — only the id has to be
188 // right, and it is the same order throughout.
189 return $response;
190 }
191
192 /** Persist the order behavior assigned to a controller-owned protocol phase. */
193 public function persist( string $phase, int $id, array $payload, array $current = array(), array $response_data = array(), array $context = array() ): void {
194 if ( 'create_before_identity' === $phase ) {
195 $this->order_payload->persist_tax_ids( $id, $payload, true );
196 } elseif ( 'create_after_identity' === $phase ) {
197 $this->stamp_order_audit( $id, $payload, true );
198 $order = wc_get_order( $id );
199 if ( $order ) {
200 Order_Notes::add_creation_note( $order, get_current_user_id(), $order->get_meta( '_pos_store' ) );
201 }
202 } elseif ( 'create_recovery' === $phase ) {
203 $this->stamp_order_audit( $id, $payload, false );
204 $this->order_payload->persist_tax_ids( $id, $payload, true );
205 } elseif ( 'update' === $phase ) {
206 $this->stamp_order_till_meta( $id, $payload );
207 $this->order_payload->persist_tax_ids( $id, $payload, false );
208 $this->persist_cashier_store_reassignment( $id, $current, $response_data, $context );
209 if ( ! empty( $context['clear_email'] ) ) {
210 $order = wc_get_order( $id );
211 if ( $order ) {
212 $order->set_billing_email( '' );
213 $order->get_data_store()->update( $order );
214 }
215 }
216 }
217 }
218
219 /** Execute delete with the named stock restore/rollback lifecycle. */
220 public function delete( array $meta, int $id, array $mutation, callable $dispatch, callable $can_delete ) {
221 $force = (bool) ( $mutation['force'] ?? false );
222 return $this->forward_with_stock_restore_rollback(
223 $this->delete_request( $meta['route'] . '/' . $id, $id, $force ),
224 $id,
225 $force,
226 $dispatch,
227 $can_delete
228 );
229 }
230
231 /** Read an order with six-decimal precision and POS links. */
232 public function document( array $meta, int $id, callable $default_document ) {
233 $response = $default_document( $meta, $id, array( 'dp' => '6' ) );
234 $data = $response->get_data();
235 $order = wc_get_order( $id );
236 if ( is_array( $data ) && $order ) {
237 $response->set_data( Order_Serializer::add_pos_links( $data, $order ) );
238 }
239 return $response;
240 }
241
242 /** Build the canonical augmented order response document. */
243 public function build_response_document( array $bare, string $record_id, array $meta, int $id, callable $default_builder ): array {
244 $order = wc_get_order( $id );
245 if ( $order ) {
246 $bare = ( new Order_Serializer() )->document( $bare, Order_Serializer::V2_AUGMENTATIONS, null, $order );
247 }
248 return Pos_Uuid::ensure_in_payload( $bare, $record_id );
249 }
250
251 /** Apply create/update hook policies around one exact forwarded order. */
252 private function forward_with_order_lifecycle( array $prepared, callable $forward ) {
253 $context = $prepared['context'];
254 $created_via = static function ( $order ) {
255 $intent = Order_Write_Intent::current();
256 if ( $order instanceof \WC_Order && null !== $intent && $intent->is_subject( $order ) && 'woocommerce-pos' !== $order->get_created_via() ) {
257 $order->set_created_via( 'woocommerce-pos' );
258 }
259 };
260 if ( 'create' === $context['operation'] ) {
261 add_action( 'woocommerce_before_order_object_save', $created_via );
262 }
263 try {
264 return $forward( $prepared['method'], $prepared['route'], $prepared['payload'] );
265 } finally {
266 if ( 'create' === $context['operation'] ) {
267 remove_action( 'woocommerce_before_order_object_save', $created_via );
268 }
269 }
270 }
271
272 /** Name and execute the permanent/trash stock restore/rollback protocol. */
273 private function forward_with_stock_restore_rollback( $request, int $id, bool $force, callable $dispatch, callable $can_delete ) {
274 $setting = SettingsService::instance()->restore_stock_on_delete_enabled();
275 $restore_stock = apply_filters( 'woocommerce_pos_restore_stock_on_delete', $setting, $id );
276 $pre_restored = false;
277 if ( $restore_stock && $force && $this->order_stock_reduced( $id ) && $can_delete( $id ) ) {
278 wc_maybe_increase_stock_levels( $id );
279 $pre_restored = true;
280 }
281 $response = $dispatch( $request );
282 if ( $response->get_status() >= 400 ) {
283 if ( $pre_restored ) {
284 wc_maybe_reduce_stock_levels( $id );
285 }
286 return new WP_REST_Response( $response->get_data(), $response->get_status() );
287 }
288 if ( $restore_stock && ! $force ) {
289 wc_maybe_increase_stock_levels( $id );
290 }
291 return $response;
292 }
293
294 /** Capture and authorize the cashier/store reassignment lifecycle. */
295 private function prepare_order_update_after_read( int $id, array $payload ): array {
296 $reassignment = array();
297 foreach ( is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array() as $entry ) {
298 $key = Meta_Entry::key( $entry );
299 if ( is_scalar( $key ) && in_array( (string) $key, array( '_pos_user', '_pos_store' ), true ) ) {
300 $reassignment[ (string) $key ] = Meta_Entry::value( $entry ) ?? '';
301 }
302 }
303 $authorized = isset( $reassignment['_pos_store'] ) && is_scalar( $reassignment['_pos_store'] ) && '' !== (string) $reassignment['_pos_store']
304 ? (bool) apply_filters( 'woocommerce_pos_order_store_reassignment_allowed', true, (string) $reassignment['_pos_store'], $id )
305 : false;
306 $forward = $payload;
307 unset( $forward['created_via'], $forward['tax_ids'] );
308 if ( isset( $forward['meta_data'] ) && is_array( $forward['meta_data'] ) ) {
309 $forward['meta_data'] = $this->without_pos_audit_meta( $forward, $id );
310 }
311 $clear_email = isset( $forward['billing'] ) && is_array( $forward['billing'] )
312 && array_key_exists( 'email', $forward['billing'] ) && '' === $forward['billing']['email'];
313 $fill_meta = array();
314 $pre_store = null;
315 $order = wc_get_order( $id );
316 if ( $order ) {
317 /*
318 * `set_paid` is write-only in wc/v3, so a client re-sends it on every
319 * later edit of an order it created offline. WooCommerce only takes
320 * payment on update when the order still needs it (`$creating ||
321 * needs_payment()` in its orders controller) — mirror that, pre-forward,
322 * or an update to an order ALREADY paid by a real gateway (hosted pay
323 * page) would stamp 'offline' over a gateway-taken payment: the exact
324 * distinction this marker exists to draw. Fill-only, never overwrite.
325 */
326 if ( self::asserts_payment( $payload ) && $order->needs_payment() && '' === (string) $order->get_meta( self::PAYMENT_ASSERTED_META ) ) {
327 $fill_meta[ self::PAYMENT_ASSERTED_META ] = self::PAYMENT_ASSERTED_OFFLINE;
328 }
329 $pre_store = (string) $order->get_meta( '_pos_store' );
330 foreach ( array( '_pos_user', '_pos_user_created' ) as $key ) {
331 if ( '' === (string) $order->get_meta( $key ) ) {
332 $fill_meta[ $key ] = (string) get_current_user_id();
333 }
334 }
335 $till = Pos_Order_Audit::till_meta_from_payload( is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array() );
336 foreach ( Pos_Order_Audit::cash_meta_keys() as $key ) {
337 if ( isset( $till[ $key ] ) && '' === (string) $order->get_meta( $key ) ) {
338 $fill_meta[ $key ] = $till[ $key ];
339 }
340 }
341 if ( $authorized && (string) $order->get_meta( '_pos_store' ) !== (string) $reassignment['_pos_store'] ) {
342 $fill_meta['_pos_store'] = (string) $reassignment['_pos_store'];
343 }
344 }
345 return array(
346 'payload' => $this->order_payload->for_update( $id, $forward ),
347 'context' => array(
348 'operation' => 'update',
349 'id' => $id,
350 'clear_email' => $clear_email,
351 'reassignment' => $reassignment,
352 'store_authorized' => $authorized,
353 'pre_store' => $pre_store,
354 'fill_meta' => $fill_meta,
355 ),
356 );
357 }
358
359 /** Persist reassignment and its mutually exclusive order-note policy. */
360 private function persist_cashier_store_reassignment( int $id, array $current, array $data, array $context ): void {
361 $order = wc_get_order( $id );
362 if ( ! $order || ! is_array( $data ) ) {
363 return;
364 }
365 $current_user = get_current_user_id();
366 $change = $context['reassignment'];
367 $old_user = $order->get_meta( '_pos_user' );
368 $old_store = null !== $context['pre_store'] ? $context['pre_store'] : $order->get_meta( '_pos_store' );
369 $cashier_changed = isset( $change['_pos_user'] ) && is_numeric( $change['_pos_user'] )
370 && (int) $change['_pos_user'] === $current_user && (string) $old_user !== (string) $current_user;
371 $store_changed = $context['store_authorized'] && (string) $old_store !== (string) $change['_pos_store'];
372 if ( $cashier_changed ) {
373 $order->update_meta_data( '_pos_user', (string) $current_user );
374 }
375 if ( $store_changed ) {
376 $order->update_meta_data( '_pos_store', (string) $change['_pos_store'] );
377 }
378 if ( $cashier_changed || $store_changed ) {
379 $order->save();
380 }
381 if ( 'pos-open' === ( $data['status'] ?? '' ) && 'pos-open' !== ( $current['status'] ?? '' ) ) {
382 Order_Notes::add_reopen_note( $order, $current_user, $order->get_meta( '_pos_store' ) );
383 } else {
384 if ( $cashier_changed ) {
385 Order_Notes::add_cashier_change_note( $order, $old_user, $current_user );
386 }
387 if ( $store_changed ) {
388 Order_Notes::add_store_change_note( $order, $old_store, $change['_pos_store'] );
389 }
390 }
391 if ( array_key_exists( 'customer_id', $current ) && array_key_exists( 'customer_id', $data ) && (int) $current['customer_id'] !== (int) $data['customer_id'] ) {
392 Order_Notes::add_pos_customer_change_note( $order, $current['customer_id'], $data['customer_id'] );
393 }
394 }
395
396 /** Persist server-owned order audit metadata. */
397 private function stamp_order_audit( int $id, array $payload, bool $stamp_version ): void {
398 $meta = array( '_pos_user' => (string) get_current_user_id() );
399 // Create-shaped phases only (create_after_identity, create_recovery, the
400 // born-twice repair): the create push itself asserted the payment, so no
401 // needs_payment() gate — by the time this runs post-forward the order is
402 // already paid BY THIS PUSH. The update path carries its own guard.
403 if ( self::asserts_payment( $payload ) ) {
404 $meta[ self::PAYMENT_ASSERTED_META ] = self::PAYMENT_ASSERTED_OFFLINE;
405 }
406 if ( $stamp_version ) {
407 $meta['_woocommerce_pos_version'] = VERSION;
408 $meta['_pos_user_created'] = $meta['_pos_user'];
409 }
410 $payload_meta = is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array();
411 $this->store->persist_order_audit_meta( $id, array_merge( $meta, Pos_Order_Audit::till_meta_from_payload( $payload_meta ) ), 'woocommerce-pos' );
412 }
413
414 /** Persist missing cash-tender metadata after update. */
415 private function stamp_order_till_meta( int $id, array $payload ): void {
416 $meta = array();
417 foreach ( is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array() as $entry ) {
418 $key = Meta_Entry::key( $entry );
419 $value = Meta_Entry::value( $entry ) ?? '';
420 if ( is_scalar( $key ) && in_array( (string) $key, Pos_Order_Audit::cash_meta_keys(), true ) && is_scalar( $value ) && '' !== (string) $value ) {
421 $meta[ (string) $key ] = (string) $value;
422 }
423 }
424 if ( $meta ) {
425 $this->store->persist_order_audit_meta( $id, $meta );
426 }
427 }
428
429 /** Strip server-owned audit metadata from a forward. */
430 private function without_pos_audit_meta( array $payload, int $id = 0 ): array {
431 $meta = is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array();
432 $protected = $id > 0 ? Pos_Order_Audit::audit_meta_ids( wc_get_order( $id ) ) : array();
433 return Pos_Order_Audit::strip_audit_meta( $meta, $protected );
434 }
435
436 /** Whether order stock was actually reduced before delete. */
437 private function order_stock_reduced( int $id ): bool {
438 $order = wc_get_order( $id );
439 return $order instanceof \WC_Order && (bool) $order->get_data_store()->get_stock_reduced( $id );
440 }
441 }
442