store = $store; $this->order_payload = $order_payload ?? new Order_Write_Payload(); } /** Prepare an order create and its create-only hook policy. */ public function prepare_create( array $meta, array $payload, callable $validate_tax_ids ) { $created_gmt = $this->order_payload->validate_client_created_gmt( $payload ); if ( is_wp_error( $created_gmt ) ) { return $created_gmt; } $error = $validate_tax_ids( $payload ); if ( is_wp_error( $error ) ) { return $error; } $forward = $payload; $forward['created_via'] = 'woocommerce-pos'; unset( $forward['tax_ids'] ); if ( isset( $forward['meta_data'] ) && is_array( $forward['meta_data'] ) ) { $forward['meta_data'] = $this->without_pos_audit_meta( $forward ); } $meta_data = isset( $payload['meta_data'] ) && is_array( $payload['meta_data'] ) ? $payload['meta_data'] : array(); $till_meta = Pos_Order_Audit::till_meta_from_payload( $meta_data ); $till_meta['_pos_user'] = (string) get_current_user_id(); $till_meta['_pos_user_created'] = $till_meta['_pos_user']; if ( self::asserts_payment( $payload ) ) { // A CREATE carrying set_paid IS the payment event — the push itself // marks the fresh order paid with no gateway involved. $till_meta[ self::PAYMENT_ASSERTED_META ] = self::PAYMENT_ASSERTED_OFFLINE; } return array( 'method' => 'POST', 'route' => $meta['route'], 'payload' => $this->order_payload->for_create( $forward ), 'context' => array( 'operation' => 'create', 'created_gmt' => $created_gmt, 'fill_meta' => $till_meta, ), ); } /** Prepare an order update and its update-only hook/reassignment policy. */ public function prepare_update( array $meta, int $id, array $payload, callable $validate_tax_ids ) { $error = $validate_tax_ids( $payload ); if ( is_wp_error( $error ) ) { return $error; } return array( 'method' => 'PUT', 'route' => $meta['route'] . '/' . $id, 'payload' => $payload, 'context' => array(), 'context_factory' => function () use ( $id, $payload ) { return $this->prepare_order_update_after_read( $id, $payload ); }, ); } /** Whether the push payload asserts payment via wc/v3's write-only set_paid flag. */ private static function asserts_payment( array $payload ): bool { return isset( $payload['set_paid'] ) && rest_sanitize_boolean( $payload['set_paid'] ); } /** Repair an existing born-twice order without inventing a version stamp. */ public function validate_existing_create( int $id, array $payload, array $prepared ) { // The repair runs against an EXISTING order: if a gateway paid it between // the two create arrivals, the replayed set_paid no longer describes this // order's payment — same needs_payment() guard as the update path. The // recovery phases keep stamping unconditionally: there the order was paid // by this very push. $order = wc_get_order( $id ); if ( $order && ! $order->needs_payment() && '' === (string) $order->get_meta( self::PAYMENT_ASSERTED_META ) ) { unset( $payload['set_paid'] ); } $this->stamp_order_audit( $id, $payload, false ); return null; } /** Forward within the named order hook lifecycle. */ public function forward( array $prepared, callable $forward ) { $declared = $prepared['context']; if ( ! in_array( $declared['operation'] ?? '', array( 'create', 'update' ), true ) ) { return $this->forward_with_reserved_stock( $prepared, $forward ); } $payload = $prepared['payload']; $declared['requested_status'] = isset( $payload['status'] ) ? (string) $payload['status'] : ''; $declared['set_paid'] = isset( $payload['set_paid'] ) && rest_sanitize_boolean( $payload['set_paid'] ); return Order_Write_Intent::open( $declared, function () use ( $prepared, $forward ) { return $this->forward_with_reserved_stock( $prepared, $forward ); } ); } /** * Wrap the create forward in the shared create-pending -> reserve -> complete * sequence, so this lane gets the SAME anti-overselling guarantee as wcpos/v1. * * Without this the only stock check on this lane is the `pre_insert` filter, * which runs against an unsaved order (id 0) and so can only compare * availability — it cannot take a reservation, and two terminals selling the * last unit concurrently both pass it. See Stock_Validator::around_paid_create(). * * @param array $prepared Prepared forward. * @param callable $forward Underlying wc/v3 dispatch. */ private function forward_with_reserved_stock( array $prepared, callable $forward ) { $context = $prepared['context']; $payload = $prepared['payload']; $paid = isset( $payload['set_paid'] ) && rest_sanitize_boolean( $payload['set_paid'] ); $status = isset( $payload['status'] ) ? (string) $payload['status'] : ''; $validator = Stock_Validator::instance(); if ( 'create' !== $context['operation'] || ! SettingsService::instance()->prevent_overselling_enabled() || ! $validator->should_validate_create_payload( $status, $paid ) ) { return $this->forward_with_order_lifecycle( $prepared, $forward ); } $response = null; $order = $validator->around_paid_create( array( 'status' => $status, 'set_paid' => $paid, 'transaction_id' => isset( $payload['transaction_id'] ) ? (string) $payload['transaction_id'] : '', ), function ( array $neutralised ) use ( $prepared, $forward, &$response ) { $prepared['payload']['status'] = $neutralised['status']; $prepared['payload']['set_paid'] = $neutralised['set_paid']; $response = $this->forward_with_order_lifecycle( $prepared, $forward ); $data = $response instanceof \WP_REST_Response ? $response->get_data() : null; $id = is_array( $data ) && isset( $data['id'] ) ? (int) $data['id'] : 0; return $id > 0 ? wc_get_order( $id ) : $response; } ); if ( is_wp_error( $order ) ) { return $order; } // The controller rebuilds its response document from wc_get_order( $id ) // (see document()/build_response_document()), so the forwarded body does // not need re-shaping after payment completes — only the id has to be // right, and it is the same order throughout. return $response; } /** Persist the order behavior assigned to a controller-owned protocol phase. */ public function persist( string $phase, int $id, array $payload, array $current = array(), array $response_data = array(), array $context = array() ): void { if ( 'create_before_identity' === $phase ) { $this->order_payload->persist_tax_ids( $id, $payload, true ); } elseif ( 'create_after_identity' === $phase ) { $this->stamp_order_audit( $id, $payload, true ); $order = wc_get_order( $id ); if ( $order ) { Order_Notes::add_creation_note( $order, get_current_user_id(), $order->get_meta( '_pos_store' ) ); } } elseif ( 'create_recovery' === $phase ) { $this->stamp_order_audit( $id, $payload, false ); $this->order_payload->persist_tax_ids( $id, $payload, true ); } elseif ( 'update' === $phase ) { $this->stamp_order_till_meta( $id, $payload ); $this->order_payload->persist_tax_ids( $id, $payload, false ); $this->persist_cashier_store_reassignment( $id, $current, $response_data, $context ); if ( ! empty( $context['clear_email'] ) ) { $order = wc_get_order( $id ); if ( $order ) { $order->set_billing_email( '' ); $order->get_data_store()->update( $order ); } } } } /** Execute delete with the named stock restore/rollback lifecycle. */ public function delete( array $meta, int $id, array $mutation, callable $dispatch, callable $can_delete ) { $force = (bool) ( $mutation['force'] ?? false ); return $this->forward_with_stock_restore_rollback( $this->delete_request( $meta['route'] . '/' . $id, $id, $force ), $id, $force, $dispatch, $can_delete ); } /** Read an order with six-decimal precision and POS links. */ public function document( array $meta, int $id, callable $default_document ) { $response = $default_document( $meta, $id, array( 'dp' => '6' ) ); $data = $response->get_data(); $order = wc_get_order( $id ); if ( is_array( $data ) && $order ) { $response->set_data( Order_Serializer::add_pos_links( $data, $order ) ); } return $response; } /** Build the canonical augmented order response document. */ public function build_response_document( array $bare, string $record_id, array $meta, int $id, callable $default_builder ): array { $order = wc_get_order( $id ); if ( $order ) { $bare = ( new Order_Serializer() )->document( $bare, Order_Serializer::V2_AUGMENTATIONS, null, $order ); } return Pos_Uuid::ensure_in_payload( $bare, $record_id ); } /** Apply create/update hook policies around one exact forwarded order. */ private function forward_with_order_lifecycle( array $prepared, callable $forward ) { $context = $prepared['context']; $created_via = static function ( $order ) { $intent = Order_Write_Intent::current(); if ( $order instanceof \WC_Order && null !== $intent && $intent->is_subject( $order ) && 'woocommerce-pos' !== $order->get_created_via() ) { $order->set_created_via( 'woocommerce-pos' ); } }; if ( 'create' === $context['operation'] ) { add_action( 'woocommerce_before_order_object_save', $created_via ); } try { return $forward( $prepared['method'], $prepared['route'], $prepared['payload'] ); } finally { if ( 'create' === $context['operation'] ) { remove_action( 'woocommerce_before_order_object_save', $created_via ); } } } /** Name and execute the permanent/trash stock restore/rollback protocol. */ private function forward_with_stock_restore_rollback( $request, int $id, bool $force, callable $dispatch, callable $can_delete ) { $setting = SettingsService::instance()->restore_stock_on_delete_enabled(); $restore_stock = apply_filters( 'woocommerce_pos_restore_stock_on_delete', $setting, $id ); $pre_restored = false; if ( $restore_stock && $force && $this->order_stock_reduced( $id ) && $can_delete( $id ) ) { wc_maybe_increase_stock_levels( $id ); $pre_restored = true; } $response = $dispatch( $request ); if ( $response->get_status() >= 400 ) { if ( $pre_restored ) { wc_maybe_reduce_stock_levels( $id ); } return new WP_REST_Response( $response->get_data(), $response->get_status() ); } if ( $restore_stock && ! $force ) { wc_maybe_increase_stock_levels( $id ); } return $response; } /** Capture and authorize the cashier/store reassignment lifecycle. */ private function prepare_order_update_after_read( int $id, array $payload ): array { $reassignment = array(); foreach ( is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array() as $entry ) { $key = Meta_Entry::key( $entry ); if ( is_scalar( $key ) && in_array( (string) $key, array( '_pos_user', '_pos_store' ), true ) ) { $reassignment[ (string) $key ] = Meta_Entry::value( $entry ) ?? ''; } } $authorized = isset( $reassignment['_pos_store'] ) && is_scalar( $reassignment['_pos_store'] ) && '' !== (string) $reassignment['_pos_store'] ? (bool) apply_filters( 'woocommerce_pos_order_store_reassignment_allowed', true, (string) $reassignment['_pos_store'], $id ) : false; $forward = $payload; unset( $forward['created_via'], $forward['tax_ids'] ); if ( isset( $forward['meta_data'] ) && is_array( $forward['meta_data'] ) ) { $forward['meta_data'] = $this->without_pos_audit_meta( $forward, $id ); } $clear_email = isset( $forward['billing'] ) && is_array( $forward['billing'] ) && array_key_exists( 'email', $forward['billing'] ) && '' === $forward['billing']['email']; $fill_meta = array(); $pre_store = null; $order = wc_get_order( $id ); if ( $order ) { /* * `set_paid` is write-only in wc/v3, so a client re-sends it on every * later edit of an order it created offline. WooCommerce only takes * payment on update when the order still needs it (`$creating || * needs_payment()` in its orders controller) — mirror that, pre-forward, * or an update to an order ALREADY paid by a real gateway (hosted pay * page) would stamp 'offline' over a gateway-taken payment: the exact * distinction this marker exists to draw. Fill-only, never overwrite. */ if ( self::asserts_payment( $payload ) && $order->needs_payment() && '' === (string) $order->get_meta( self::PAYMENT_ASSERTED_META ) ) { $fill_meta[ self::PAYMENT_ASSERTED_META ] = self::PAYMENT_ASSERTED_OFFLINE; } $pre_store = (string) $order->get_meta( '_pos_store' ); foreach ( array( '_pos_user', '_pos_user_created' ) as $key ) { if ( '' === (string) $order->get_meta( $key ) ) { $fill_meta[ $key ] = (string) get_current_user_id(); } } $till = Pos_Order_Audit::till_meta_from_payload( is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array() ); foreach ( Pos_Order_Audit::cash_meta_keys() as $key ) { if ( isset( $till[ $key ] ) && '' === (string) $order->get_meta( $key ) ) { $fill_meta[ $key ] = $till[ $key ]; } } if ( $authorized && (string) $order->get_meta( '_pos_store' ) !== (string) $reassignment['_pos_store'] ) { $fill_meta['_pos_store'] = (string) $reassignment['_pos_store']; } } return array( 'payload' => $this->order_payload->for_update( $id, $forward ), 'context' => array( 'operation' => 'update', 'id' => $id, 'clear_email' => $clear_email, 'reassignment' => $reassignment, 'store_authorized' => $authorized, 'pre_store' => $pre_store, 'fill_meta' => $fill_meta, ), ); } /** Persist reassignment and its mutually exclusive order-note policy. */ private function persist_cashier_store_reassignment( int $id, array $current, array $data, array $context ): void { $order = wc_get_order( $id ); if ( ! $order || ! is_array( $data ) ) { return; } $current_user = get_current_user_id(); $change = $context['reassignment']; $old_user = $order->get_meta( '_pos_user' ); $old_store = null !== $context['pre_store'] ? $context['pre_store'] : $order->get_meta( '_pos_store' ); $cashier_changed = isset( $change['_pos_user'] ) && is_numeric( $change['_pos_user'] ) && (int) $change['_pos_user'] === $current_user && (string) $old_user !== (string) $current_user; $store_changed = $context['store_authorized'] && (string) $old_store !== (string) $change['_pos_store']; if ( $cashier_changed ) { $order->update_meta_data( '_pos_user', (string) $current_user ); } if ( $store_changed ) { $order->update_meta_data( '_pos_store', (string) $change['_pos_store'] ); } if ( $cashier_changed || $store_changed ) { $order->save(); } if ( 'pos-open' === ( $data['status'] ?? '' ) && 'pos-open' !== ( $current['status'] ?? '' ) ) { Order_Notes::add_reopen_note( $order, $current_user, $order->get_meta( '_pos_store' ) ); } else { if ( $cashier_changed ) { Order_Notes::add_cashier_change_note( $order, $old_user, $current_user ); } if ( $store_changed ) { Order_Notes::add_store_change_note( $order, $old_store, $change['_pos_store'] ); } } if ( array_key_exists( 'customer_id', $current ) && array_key_exists( 'customer_id', $data ) && (int) $current['customer_id'] !== (int) $data['customer_id'] ) { Order_Notes::add_pos_customer_change_note( $order, $current['customer_id'], $data['customer_id'] ); } } /** Persist server-owned order audit metadata. */ private function stamp_order_audit( int $id, array $payload, bool $stamp_version ): void { $meta = array( '_pos_user' => (string) get_current_user_id() ); // Create-shaped phases only (create_after_identity, create_recovery, the // born-twice repair): the create push itself asserted the payment, so no // needs_payment() gate — by the time this runs post-forward the order is // already paid BY THIS PUSH. The update path carries its own guard. if ( self::asserts_payment( $payload ) ) { $meta[ self::PAYMENT_ASSERTED_META ] = self::PAYMENT_ASSERTED_OFFLINE; } if ( $stamp_version ) { $meta['_woocommerce_pos_version'] = VERSION; $meta['_pos_user_created'] = $meta['_pos_user']; } $payload_meta = is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array(); $this->store->persist_order_audit_meta( $id, array_merge( $meta, Pos_Order_Audit::till_meta_from_payload( $payload_meta ) ), 'woocommerce-pos' ); } /** Persist missing cash-tender metadata after update. */ private function stamp_order_till_meta( int $id, array $payload ): void { $meta = array(); foreach ( is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array() as $entry ) { $key = Meta_Entry::key( $entry ); $value = Meta_Entry::value( $entry ) ?? ''; if ( is_scalar( $key ) && in_array( (string) $key, Pos_Order_Audit::cash_meta_keys(), true ) && is_scalar( $value ) && '' !== (string) $value ) { $meta[ (string) $key ] = (string) $value; } } if ( $meta ) { $this->store->persist_order_audit_meta( $id, $meta ); } } /** Strip server-owned audit metadata from a forward. */ private function without_pos_audit_meta( array $payload, int $id = 0 ): array { $meta = is_array( $payload['meta_data'] ?? null ) ? $payload['meta_data'] : array(); $protected = $id > 0 ? Pos_Order_Audit::audit_meta_ids( wc_get_order( $id ) ) : array(); return Pos_Order_Audit::strip_audit_meta( $meta, $protected ); } /** Whether order stock was actually reduced before delete. */ private function order_stock_reduced( int $id ): bool { $order = wc_get_order( $id ); return $order instanceof \WC_Order && (bool) $order->get_data_store()->get_stock_reduced( $id ); } }