PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
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
← All changes | includes/API/V2/Writers/Order_Writer.php +148 -91 1.10.11.10.19 View file →
@@ -9,11 +9,12 @@
9 9
10 10 namespace WCPOS\WooCommercePOS\API\V2\Writers;
11 11
12 12 use WCPOS\WooCommercePOS\Services\Order_Notes;
13 +use WCPOS\WooCommercePOS\Services\Order_Write_Intent;
13 14 use WCPOS\WooCommercePOS\Services\Pos_Order_Audit;
14 15 use WCPOS\WooCommercePOS\Services\Settings as SettingsService;
15 -use WCPOS\WooCommercePOS\Services\Tax_Id_Writer;
16 +use WCPOS\WooCommercePOS\Services\Stock_Validator;
16 17 use WCPOS\WooCommercePOS\Sync\Meta_Entry;
17 18 use WCPOS\WooCommercePOS\Sync\Order_Serializer;
18 19 use WCPOS\WooCommercePOS\Sync\Order_Write_Payload;
19 20 use WCPOS\WooCommercePOS\Sync\Pos_Uuid;
@@ -22,8 +23,16 @@
22 23 use const WCPOS\WooCommercePOS\VERSION;
23 24
24 25 /** Owns order audit, tax, reassignment, hook, note, email, and stock behavior. */
25 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 +
26 35 /** @var object Mutation store used for HPOS-safe audit persistence. */
27 36 private $store;
28 37
29 38 /** @var Order_Write_Payload Order forward payload shaper. */
@@ -36,9 +45,9 @@
36 45 }
37 46
38 47 /** Prepare an order create and its create-only hook policy. */
39 48 public function prepare_create( array $meta, array $payload, callable $validate_tax_ids ) {
40 - $created_gmt = $this->validate_client_created_gmt( $payload );
49 + $created_gmt = $this->order_payload->validate_client_created_gmt( $payload );
41 50 if ( is_wp_error( $created_gmt ) ) {
42 51 return $created_gmt;
43 52 }
44 53 $error = $validate_tax_ids( $payload );
@@ -54,8 +63,13 @@
54 63 $meta_data = isset( $payload['meta_data'] ) && is_array( $payload['meta_data'] ) ? $payload['meta_data'] : array();
55 64 $till_meta = Pos_Order_Audit::till_meta_from_payload( $meta_data );
56 65 $till_meta['_pos_user'] = (string) get_current_user_id();
57 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 + }
58 72 return array(
59 73 'method' => 'POST',
60 74 'route' => $meta['route'],
61 75 'payload' => $this->order_payload->for_create( $forward ),
@@ -83,10 +97,24 @@
83 97 },
84 98 );
85 99 }
86 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 +
87 106 /** Repair an existing born-twice order without inventing a version stamp. */
88 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 + }
89 117 $this->stamp_order_audit( $id, $payload, false );
90 118 return null;
91 119 }
92 120
@@ -91,35 +119,108 @@
91 119 }
92 120
93 121 /** Forward within the named order hook lifecycle. */
94 122 public function forward( array $prepared, callable $forward ) {
95 - return $this->forward_with_order_lifecycle( $prepared, $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 + );
96 136 }
97 137
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 );
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 tax IDs before the identity proof. */
193 + public function after_create( int $id, array $payload ): void {
194 + $this->order_payload->persist_tax_ids( $id, $payload, true );
195 + }
196 +
197 + /** Stamp audit metadata and add the creation note after the identity proof. */
198 + public function after_identity( int $id, array $payload ): void {
199 + $this->stamp_order_audit( $id, $payload, true );
200 + $order = wc_get_order( $id );
201 + if ( $order ) {
202 + Order_Notes::add_creation_note( $order, get_current_user_id(), $order->get_meta( '_pos_store' ) );
203 + }
204 + }
205 +
206 + /** Restore audit metadata and tax IDs after recovery. */
207 + public function after_recovery( int $id, array $payload ): void {
208 + $this->stamp_order_audit( $id, $payload, false );
209 + $this->order_payload->persist_tax_ids( $id, $payload, true );
210 + }
211 +
212 + /** Persist till metadata, tax IDs, reassignment, and email changes after update. */
213 + public function after_update( int $id, array $payload, array $current, array $response_data, array $context ): void {
214 + $this->stamp_order_till_meta( $id, $payload );
215 + $this->order_payload->persist_tax_ids( $id, $payload, false );
216 + $this->persist_cashier_store_reassignment( $id, $current, $response_data, $context );
217 + if ( ! empty( $context['clear_email'] ) ) {
104 218 $order = wc_get_order( $id );
105 219 if ( $order ) {
106 - Order_Notes::add_creation_note( $order, get_current_user_id(), $order->get_meta( '_pos_store' ) );
220 + $order->set_billing_email( '' );
221 + $order->get_data_store()->update( $order );
107 222 }
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 223 }
123 224 }
124 225
125 226 /** Execute delete with the named stock restore/rollback lifecycle. */
@@ -155,35 +256,15 @@
155 256 }
156 257
157 258 /** Apply create/update hook policies around one exact forwarded order. */
158 259 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() ) {
260 + $context = $prepared['context'];
261 + $created_via = static function ( $order ) {
262 + $intent = Order_Write_Intent::current();
263 + if ( $order instanceof \WC_Order && null !== $intent && $intent->is_subject( $order ) && 'woocommerce-pos' !== $order->get_created_via() ) {
179 264 $order->set_created_via( 'woocommerce-pos' );
180 265 }
181 266 };
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 267 if ( 'create' === $context['operation'] ) {
187 268 add_action( 'woocommerce_before_order_object_save', $created_via );
188 269 }
189 270 try {
@@ -188,11 +269,8 @@
188 269 }
189 270 try {
190 271 return $forward( $prepared['method'], $prepared['route'], $prepared['payload'] );
191 272 } finally {
192 - if ( $use_filter ) {
193 - remove_filter( 'woocommerce_rest_pre_insert_shop_order_object', $pre_insert, 10 );
194 - }
195 273 if ( 'create' === $context['operation'] ) {
196 274 remove_action( 'woocommerce_before_order_object_save', $created_via );
197 275 }
198 276 }
@@ -242,8 +320,20 @@
242 320 $fill_meta = array();
243 321 $pre_store = null;
244 322 $order = wc_get_order( $id );
245 323 if ( $order ) {
324 + /*
325 + * `set_paid` is write-only in wc/v3, so a client re-sends it on every
326 + * later edit of an order it created offline. WooCommerce only takes
327 + * payment on update when the order still needs it (`$creating ||
328 + * needs_payment()` in its orders controller) — mirror that, pre-forward,
329 + * or an update to an order ALREADY paid by a real gateway (hosted pay
330 + * page) would stamp 'offline' over a gateway-taken payment: the exact
331 + * distinction this marker exists to draw. Fill-only, never overwrite.
332 + */
333 + if ( self::asserts_payment( $payload ) && $order->needs_payment() && '' === (string) $order->get_meta( self::PAYMENT_ASSERTED_META ) ) {
334 + $fill_meta[ self::PAYMENT_ASSERTED_META ] = self::PAYMENT_ASSERTED_OFFLINE;
335 + }
246 336 $pre_store = (string) $order->get_meta( '_pos_store' );
247 337 foreach ( array( '_pos_user', '_pos_user_created' ) as $key ) {
248 338 if ( '' === (string) $order->get_meta( $key ) ) {
249 339 $fill_meta[ $key ] = (string) get_current_user_id();
@@ -309,24 +399,18 @@
309 399 Order_Notes::add_pos_customer_change_note( $order, $current['customer_id'], $data['customer_id'] );
310 400 }
311 401 }
312 402
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 403 /** Persist server-owned order audit metadata. */
327 404 private function stamp_order_audit( int $id, array $payload, bool $stamp_version ): void {
328 405 $meta = array( '_pos_user' => (string) get_current_user_id() );
406 + // Create-shaped phases only (create_after_identity, create_recovery, the
407 + // born-twice repair): the create push itself asserted the payment, so no
408 + // needs_payment() gate — by the time this runs post-forward the order is
409 + // already paid BY THIS PUSH. The update path carries its own guard.
410 + if ( self::asserts_payment( $payload ) ) {
411 + $meta[ self::PAYMENT_ASSERTED_META ] = self::PAYMENT_ASSERTED_OFFLINE;
412 + }
329 413 if ( $stamp_version ) {
330 414 $meta['_woocommerce_pos_version'] = VERSION;
331 415 $meta['_pos_user_created'] = $meta['_pos_user'];
332 416 }
@@ -353,35 +437,8 @@
353 437 private function without_pos_audit_meta( array $payload, int $id = 0 ): array {
354 438 $meta = is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array();
355 439 $protected = $id > 0 ? Pos_Order_Audit::audit_meta_ids( wc_get_order( $id ) ) : array();
356 440 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 441 }
385 442
386 443 /** Whether order stock was actually reduced before delete. */
387 444 private function order_stock_reduced( int $id ): bool {