* * @see http://wcpos.com * @package WCPOS\WooCommercePOS */ namespace WCPOS\WooCommercePOS; use WC_Abstract_Order; use WC_Coupon; use WC_Order; use WC_Order_Item; use WC_Order_Item_Product; use WC_Order_Item_Shipping; use WC_Discounts; use WC_Product; use WC_Product_Simple; use WC_Tax; /** * Orders Class * - runs for all life cycles. */ class Orders { /** * Map of temporary product IDs to category IDs for coupon validation. * * Static because multiple Orders instances may register hooks on the same * filter (plugin init + test setUp). All instances must recognize temp IDs * assigned by any other instance to avoid invalid DB reads. * * @var array */ private static $temp_product_categories = array(); /** * Counter for generating unique temporary product IDs. * * Initialized with a random offset per request to avoid collisions when * a persistent object cache backend (Redis/Memcached) is active and * concurrent requests prime the same cache groups. * * @var int */ private static $temp_id_counter = 0; /** * Constructor. */ public function __construct() { $this->register_order_status(); add_filter( 'wc_order_statuses', array( $this, 'wc_order_statuses' ), 10, 1 ); add_filter( 'woocommerce_order_needs_payment', array( $this, 'order_needs_payment' ), 10, 3 ); add_filter( 'woocommerce_valid_order_statuses_for_payment', array( $this, 'valid_order_statuses_for_payment' ), 10, 2 ); add_filter( 'woocommerce_valid_order_statuses_for_payment_complete', array( $this, 'valid_order_statuses_for_payment_complete' ), 10, 2 ); add_filter( 'woocommerce_payment_complete_order_status', array( $this, 'payment_complete_order_status' ), 10, 3 ); add_filter( 'woocommerce_bacs_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 ); add_filter( 'woocommerce_cheque_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 ); add_filter( 'woocommerce_cod_process_payment_order_status', array( $this, 'offline_process_payment_order_status' ), 10, 2 ); add_filter( 'woocommerce_hidden_order_itemmeta', array( $this, 'hidden_order_itemmeta' ) ); add_filter( 'woocommerce_order_item_product', array( $this, 'order_item_product' ), 10, 2 ); add_filter( 'woocommerce_order_get_tax_location', array( $this, 'get_tax_location' ), 10, 2 ); add_action( 'woocommerce_order_item_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) ); add_action( 'woocommerce_order_item_shipping_after_calculate_taxes', array( $this, 'order_item_after_calculate_taxes' ) ); add_action( 'woocommerce_order_item_fee_after_calculate_taxes', array( __CLASS__, 'fee_after_calculate_taxes' ), 10, 2 ); add_filter( 'woocommerce_coupon_get_items_to_validate', array( $this, 'coupon_get_items_to_validate' ), 10, 2 ); add_filter( 'woocommerce_coupon_is_valid_for_product', array( $this, 'coupon_is_valid_for_product' ), 10, 4 ); add_action( 'woocommerce_order_after_calculate_totals', array( __CLASS__, 'cleanup_temp_caches' ), 999 ); } /** * Add custom POS order statuses. * * @param array $order_statuses Existing order statuses. * * @return array */ public function wc_order_statuses( array $order_statuses ): array { $order_statuses['wc-pos-open'] = /* translators: POS order status label. */ _x( 'POS - Open', 'Order status', 'woocommerce-pos' ); $order_statuses['wc-pos-partial'] = _x( 'POS - Partial Payment', 'Order status', 'woocommerce-pos' ); return $order_statuses; } /** * WooCommerce order-pay form won't allow processing of orders with total = 0. * * NOTE: $needs_payment is meant to be a boolean, but I have seen it as null. * * @param bool $needs_payment Whether payment is needed. * @param WC_Abstract_Order $order The order object. * @param array $valid_order_statuses Valid order statuses for payment. * * @return bool */ public function order_needs_payment( $needs_payment, WC_Abstract_Order $order, array $valid_order_statuses ) { // If the order total is zero and status is a POS status, then allow payment to be taken, ie: Gift Card. if ( 0 == $order->get_total() && \in_array( $order->get_status(), array( 'pos-open', 'pos-partial' ), true ) ) { return true; } return $needs_payment; } /** * Note: the wc- prefix is not used here because it is added by WooCommerce. * * @param array $order_statuses Valid order statuses. * @param WC_Abstract_Order $order The order object. * * @return array */ public function valid_order_statuses_for_payment( array $order_statuses, WC_Abstract_Order $order ): array { $order_statuses[] = 'pos-open'; $order_statuses[] = 'pos-partial'; return $order_statuses; } /** * Valid order statuses for payment complete. * * @param array $order_statuses Valid order statuses. * @param WC_Abstract_Order $order The order object. * * @return array */ public function valid_order_statuses_for_payment_complete( array $order_statuses, WC_Abstract_Order $order ): array { $order_statuses[] = 'pos-open'; $order_statuses[] = 'pos-partial'; return $order_statuses; } /** * Payment complete order status. * POS orders are also matched by order origin because gateway webhooks and * reconciliation crons complete payment outside POS requests. * * @param string $status Order status. * @param int $id Order ID. * @param WC_Abstract_Order $order The order object. * * @return string */ public function payment_complete_order_status( string $status, int $id, WC_Abstract_Order $order ): string { if ( woocommerce_pos_request() || woocommerce_pos_is_pos_order( $order ) ) { return $this->normalize_status( $this->get_gateway_order_status( $order->get_payment_method() ), $status ); } return $status; } /** * Process payment order status for offline gateways (BACS, cheque, COD). * * @param string $status Order status from gateway. * @param WC_Abstract_Order $order The order object. * * @return string */ public function offline_process_payment_order_status( string $status, WC_Abstract_Order $order ): string { if ( ! woocommerce_pos_request() ) { return $status; } if ( ! $order->get_id() ) { return $status; } if ( ! woocommerce_pos_is_pos_order( $order ) ) { return $status; } return $this->normalize_status( $this->get_gateway_order_status( $order->get_payment_method() ), $status ); } /** * Normalise a configured gateway order status for the WooCommerce status filters. * * Both `woocommerce_payment_complete_order_status` and the offline gateway * `*_process_payment_order_status` filters expect a status *without* the `wc-` * prefix, so the prefix is stripped and the result validated against the * registered order statuses. Anything empty or unrecognised falls back. * * @param string $candidate The configured status, which may carry the `wc-` prefix. * @param string $fallback Status to return when the candidate is empty or unknown. * * @return string */ private function normalize_status( string $candidate, string $fallback ): string { $normalized_status = 0 === strpos( $candidate, 'wc-' ) ? substr( $candidate, 3 ) : $candidate; if ( '' === $normalized_status ) { return $fallback; } $valid_statuses = array_map( function ( string $order_status ): string { return 0 === strpos( $order_status, 'wc-' ) ? substr( $order_status, 3 ) : $order_status; }, array_keys( wc_get_order_statuses() ) ); return \in_array( $normalized_status, $valid_statuses, true ) ? $normalized_status : $fallback; } /** * Resolve the configured POS order status for a given payment gateway. * * Looks up the per-gateway order_status from payment_gateways settings. * Falls back to 'wc-completed' if no setting is found. * * @param string $gateway_id The payment gateway ID. * * @return string The configured order status (may include wc- prefix). */ private function get_gateway_order_status( string $gateway_id ): string { $gateway_settings = woocommerce_pos_get_settings( 'payment_gateways' ); if ( is_array( $gateway_settings ) && isset( $gateway_settings['gateways'][ $gateway_id ]['order_status'] ) && is_string( $gateway_settings['gateways'][ $gateway_id ]['order_status'] ) && '' !== $gateway_settings['gateways'][ $gateway_id ]['order_status'] ) { return $gateway_settings['gateways'][ $gateway_id ]['order_status']; } return 'wc-completed'; } /** * Hides uuid from appearing on Order Edit page. * * @param array $meta_keys Hidden meta keys. * * @return array */ public function hidden_order_itemmeta( array $meta_keys ): array { return array_merge( $meta_keys, array( '_woocommerce_pos_uuid', '_woocommerce_pos_tax_status', '_woocommerce_pos_data' ) ); } /** * Filter the product object for an order item. * * @param bool|WC_Product $product The product object or false if not found. * @param WC_Order_Item_Product $item The order item object. * * @return bool|WC_Product */ public function order_item_product( $product, $item ) { $pos_data_json = $item->get_meta( '_woocommerce_pos_data', true ); // For misc products (product_id=0), create a synthetic WC_Product_Simple. // Requires _woocommerce_pos_data to distinguish POS items from other plugins. if ( 0 === $item->get_product_id() ) { if ( ! $pos_data_json ) { return $product; } $product = new WC_Product_Simple(); $product->set_name( $item->get_name() ); $sku = $item->get_meta( '_sku', true ); if ( $sku ) { $this->set_synthetic_product_sku( $product, $sku ); } // Misc products are synthetic and never persisted to DB, so we can // safely apply POS price context directly. Shape-tolerant read: the // storage may hold the historical JSON string or a native array // (after a typed sync push lands through wc/v3). $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json ); if ( \is_array( $pos_data ) ) { if ( isset( $pos_data['price'] ) ) { $product->set_price( $pos_data['price'] ); } if ( isset( $pos_data['regular_price'] ) ) { $product->set_regular_price( $pos_data['regular_price'] ); } if ( isset( $pos_data['tax_status'] ) ) { $product->set_tax_status( $pos_data['tax_status'] ); } if ( ! empty( $pos_data['virtual'] ) ) { $product->set_virtual( true ); } if ( ! empty( $pos_data['downloadable'] ) ) { $product->set_downloadable( true ); } if ( ! empty( $pos_data['categories'] ) && is_array( $pos_data['categories'] ) ) { $category_ids = array_filter( array_map( 'intval', array_column( $pos_data['categories'], 'id' ) ) ); $product->set_category_ids( $category_ids ); } if ( $this->is_pos_discounted_item_on_sale( $item, $product ) && isset( $pos_data['price'] ) ) { $product->set_sale_price( $pos_data['price'] ); } } return $product; } // For real products, only apply POS overrides when pos_data exists. if ( ! $product || empty( $pos_data_json ) ) { return $product; } $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json ); if ( ! \is_array( $pos_data ) ) { return $product; } // Use an isolated product instance for coupon-specific context. if ( $product->get_id() ) { $product = wc_get_product_object( $product->get_type(), $product->get_id() ); } if ( isset( $pos_data['tax_status'] ) ) { $product->set_tax_status( $pos_data['tax_status'] ); } return $product; } /** * Provide coupon-validation products with POS context (sale state, tax status). * * This runs only inside WC_Discounts. We return per-line-item product objects * so coupon rules (exclude_sale_items, tax-aware discount amounts) use POS data * without mutating products used by stock update routines. * * @param array $items Discount items (stdClass objects). * @param WC_Discounts $discounts Discounts context. * * @return array */ public function coupon_get_items_to_validate( array $items, WC_Discounts $discounts ): array { $object = $discounts->get_object(); if ( ! $object instanceof WC_Order || ! woocommerce_pos_is_pos_order( $object ) ) { return $items; } foreach ( $items as $index => $discount_item ) { if ( ! isset( $discount_item->object ) || ! $discount_item->object instanceof WC_Order_Item_Product ) { continue; } $original_product = isset( $discount_item->product ) && $discount_item->product instanceof WC_Product ? $discount_item->product : null; $coupon_product = $this->build_coupon_product_context( $discount_item->object, $original_product ); if ( $coupon_product instanceof WC_Product ) { $items[ $index ]->product = $coupon_product; } } return $items; } /** * Build a product object used only for coupon validation/calculation. * * @param WC_Order_Item_Product $item Order item. * @param WC_Product|null $product Current product object. * * @return WC_Product|null */ private function build_coupon_product_context( WC_Order_Item_Product $item, ?WC_Product $product = null ): ?WC_Product { $pos_data = $this->get_pos_item_data( $item ); if ( null === $pos_data ) { return $product; } $is_temp_id = $product && isset( self::$temp_product_categories[ $product->get_id() ] ); if ( $product && $product->get_id() && ! $is_temp_id ) { // Get a fresh product instance to apply POS overrides. $product = wc_get_product_object( $product->get_type(), $product->get_id() ); } elseif ( 0 === $item->get_product_id() ) { $product = new WC_Product_Simple(); $product->set_name( $item->get_name() ); $sku = $item->get_meta( '_sku', true ); if ( $sku ) { $this->set_synthetic_product_sku( $product, $sku ); } } if ( ! $product ) { return null; } if ( isset( $pos_data['price'] ) ) { $product->set_price( $pos_data['price'] ); } if ( isset( $pos_data['regular_price'] ) ) { $product->set_regular_price( $pos_data['regular_price'] ); } if ( isset( $pos_data['tax_status'] ) ) { $product->set_tax_status( $pos_data['tax_status'] ); } if ( ! empty( $pos_data['virtual'] ) ) { $product->set_virtual( true ); } if ( ! empty( $pos_data['downloadable'] ) ) { $product->set_downloadable( true ); } if ( ! empty( $pos_data['categories'] ) && is_array( $pos_data['categories'] ) ) { $category_ids = array_filter( array_map( 'intval', array_column( $pos_data['categories'], 'id' ) ) ); $product->set_category_ids( $category_ids ); // Assign a temporary non-zero ID and prime WP caches so that // WC's get_the_terms() (called via wc_get_product_cat_ids) finds // our categories. get_the_terms() requires get_post() to succeed // and checks the object term cache before querying the DB. if ( 0 === $product->get_id() && ! empty( $category_ids ) ) { if ( 0 === self::$temp_id_counter ) { // Use a random offset so concurrent requests don't collide // when a persistent object cache is active. self::$temp_id_counter = PHP_INT_MAX - wp_rand( 0, 999999 ); } $temp_id = self::$temp_id_counter--; $product->set_id( $temp_id ); self::$temp_product_categories[ $temp_id ] = $category_ids; // Prime post cache so get_post(temp_id) succeeds. $fake_post = new \stdClass(); $fake_post->ID = $temp_id; $fake_post->post_type = 'product'; $fake_post->post_status = 'publish'; $fake_post->filter = 'raw'; $fake_post->post_parent = 0; $fake_post->post_title = ''; $fake_post->post_content = ''; $fake_post->post_excerpt = ''; $fake_post->post_date = ''; $fake_post->post_date_gmt = ''; wp_cache_set( $temp_id, $fake_post, 'posts' ); // Prime term cache so get_object_term_cache() returns our IDs. // get_the_terms() checks this cache before querying the DB, // which is how validate_coupon_product_categories sees our categories. wp_cache_set( $temp_id, $category_ids, 'product_cat_relationships' ); } } if ( $this->is_pos_discounted_item_on_sale( $item, $product ) && isset( $pos_data['price'] ) ) { $product->set_sale_price( $pos_data['price'] ); } return $product; } /** * Override coupon category validation for misc products. * * WooCommerce uses wc_get_product_cat_ids( $product->get_id() ) to check * product_categories and excluded_product_categories coupon restrictions. * Synthetic misc products use temporary non-zero IDs so WC's DB lookup * doesn't short-circuit. This filter re-evaluates using per-item categories. * * @param bool $valid Whether the coupon is valid for the product. * @param WC_Product $product Product being validated. * @param WC_Coupon $coupon Coupon being applied. * @param mixed $values Values (order item or cart item data). * * @return bool */ public function coupon_is_valid_for_product( bool $valid, $product, $coupon, $values ): bool { if ( ! $product instanceof WC_Product ) { return $valid; } // Only handle products with temp IDs assigned by build_coupon_product_context. $product_id = $product->get_id(); if ( ! isset( self::$temp_product_categories[ $product_id ] ) ) { return $valid; } $product_cats = $product->get_category_ids(); if ( empty( $product_cats ) ) { return $valid; } // Include parent categories for hierarchy matching (parity with wc_get_product_cat_ids). foreach ( $product_cats as $cat ) { $product_cats = array_merge( $product_cats, get_ancestors( $cat, 'product_cat' ) ); } $product_cats = array_unique( $product_cats ); // Re-evaluate product_categories restriction. $coupon_cats = $coupon->get_product_categories(); if ( ! empty( $coupon_cats ) ) { $valid = $valid && count( array_intersect( $product_cats, $coupon_cats ) ) > 0; } // Re-evaluate excluded_product_categories restriction. $excluded_cats = $coupon->get_excluded_product_categories(); if ( ! empty( $excluded_cats ) && count( array_intersect( $product_cats, $excluded_cats ) ) > 0 ) { $valid = false; } return $valid; } /** * Remove temporary cache entries created by build_coupon_product_context(). * * Hooked to woocommerce_order_after_calculate_totals (after all coupon * validation is complete) so that persistent object cache backends * (Redis/Memcached) don't accumulate stale entries across requests. */ public static function cleanup_temp_caches(): void { foreach ( array_keys( self::$temp_product_categories ) as $temp_id ) { wp_cache_delete( $temp_id, 'posts' ); wp_cache_delete( $temp_id, 'product_cat_relationships' ); } self::$temp_product_categories = array(); self::$temp_id_counter = 0; } /** * Determine whether an order item should be treated as "on sale" in coupon checks. * * @param WC_Order_Item_Product $item Order item. * @param WC_Product|null $product Product context (optional). * * @return bool */ private function is_pos_discounted_item_on_sale( WC_Order_Item_Product $item, ?WC_Product $product = null ): bool { $pos_data = $this->get_pos_item_data( $item ); if ( null === $pos_data || ! isset( $pos_data['price'], $pos_data['regular_price'] ) ) { return false; } $is_on_sale = (float) $pos_data['price'] < (float) $pos_data['regular_price']; $product = $product ? $product : $item->get_product(); return (bool) apply_filters( 'woocommerce_pos_item_is_on_sale', $is_on_sale, $product, $item, $pos_data ); } /** * Decode _woocommerce_pos_data from an order item. * * @param WC_Order_Item_Product $item Order item. * * @return array|null */ private function get_pos_item_data( WC_Order_Item_Product $item ): ?array { $pos_data_json = $item->get_meta( '_woocommerce_pos_data', true ); if ( empty( $pos_data_json ) ) { return null; } return \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $pos_data_json ); } /** * Get tax location for this order. * * @param array $args Override the location. * @param WC_Abstract_Order $order The order object. * * @return array */ public function get_tax_location( $args, WC_Abstract_Order $order ) { if ( ! woocommerce_pos_is_pos_order( $order ) ) { return $args; } $tax_based_on = $order->get_meta( '_woocommerce_pos_tax_based_on' ); if ( $order instanceof WC_Order ) { if ( 'billing' == $tax_based_on ) { $args['country'] = $order->get_billing_country(); $args['state'] = $order->get_billing_state(); $args['postcode'] = $order->get_billing_postcode(); $args['city'] = $order->get_billing_city(); } elseif ( 'shipping' == $tax_based_on ) { $args['country'] = $order->get_shipping_country(); $args['state'] = $order->get_shipping_state(); $args['postcode'] = $order->get_shipping_postcode(); $args['city'] = $order->get_shipping_city(); } else { $args['country'] = WC()->countries->get_base_country(); $args['state'] = WC()->countries->get_base_state(); $args['postcode'] = WC()->countries->get_base_postcode(); $args['city'] = WC()->countries->get_base_city(); } } return $args; } /** * Respect a negative fee line's own tax_status and tax_class on POS-marked requests. * * WooCommerce routes negative fees through its discount tax path, disregarding the * fee's tax_status and tax_class and allocating line-item tax rates proportionally * instead. The v1 controller corrected this per-dispatch (issue #1403 row 2); this * global, request-gated registration serves both the v1 routes and the v2 push's * inner wc/v3 forward (which carries the X-WCPOS header) with one implementation. * Static so V1\Orders_Controller can delegate without constructing the service. * * @param \WC_Order_Item_Fee $fee_item The fee item. * @param array $calculate_tax_for The tax calculation location data. * * @return void */ public static function fee_after_calculate_taxes( $fee_item, $calculate_tax_for ): void { if ( $fee_item->get_total() >= 0 ) { return; } // Gate on the ORDER being a POS order (durable — survives wp-admin // Recalculate, bulk actions, and third-party recalculations), with the // POS request marker only as the supplement for the creation moment, // before the order is marked. A per-request-only gate silently flipped a // POS order's fee tax whenever a non-POS caller recalculated it. // // STOPGAP (2026-08-06 ruling): this preserves the existing POS fee-tax // semantics consistently, but the semantics themselves are slated for // replacement — negative fees are disowned by WooCommerce and the // override over-declares VAT on tax-inclusive stores. The plan of record // is migrating till discounts to virtual percent coupons; see // .claude/research/2026-08-06-wc-negative-fee-tax.md. // wcpos_is_pos_order() safely returns false for any non-order input. if ( ! wcpos_is_pos_order( $fee_item->get_order() ) && ! wcpos_request() ) { return; } if ( 'taxable' === $fee_item->get_tax_status() ) { // Use the fee's own tax_class if set, otherwise the default class. $tax_class = $fee_item->get_tax_class(); $calculate_tax_for['tax_class'] = $tax_class ? $tax_class : ''; $tax_rates = WC_Tax::find_rates( $calculate_tax_for ); $discount_taxes = WC_Tax::calc_tax( (float) $fee_item->get_total(), $tax_rates ); $fee_item->set_taxes( array( 'total' => $discount_taxes ) ); } else { // Clear taxes entirely when the fee's tax_status is 'none'. $fee_item->set_taxes( array() ); } $fee_item->save(); } /** * Calculate taxes for an order item. * * @param WC_Order_Item|WC_Order_Item_Shipping $item Order item object. * * @return void */ public function order_item_after_calculate_taxes( $item ): void { $meta_data = $item->get_meta_data(); foreach ( $meta_data as $meta ) { if ( '_woocommerce_pos_data' === $meta->key ) { $pos_data = \WCPOS\WooCommercePOS\Sync\Meta_Normalizer::decode_to_array( $meta->value ); if ( null !== $pos_data ) { if ( isset( $pos_data['tax_status'] ) && 'none' == $pos_data['tax_status'] ) { $item->set_taxes( false ); } } else { Logger::log( 'Unreadable _woocommerce_pos_data meta value on order item.' ); } break; } } } /** * Register the POS order statuses. */ private function register_order_status(): void { // Order status for open orders. register_post_status( 'wc-pos-open', array( 'label' => /* translators: POS order status label. */ _x( 'POS - Open', 'Order status', 'woocommerce-pos' ), 'public' => true, 'exclude_from_search' => false, 'show_in_admin_all_list' => true, 'show_in_admin_status_list' => true, // translators: %s is the number of orders with POS - Open status. 'label_count' => _n_noop( 'POS - Open (%s)', 'POS - Open (%s)', 'woocommerce-pos' ), ) ); // Order status for partial payment orders. register_post_status( 'wc-pos-partial', array( 'label' => _x( 'POS - Partial Payment', 'Order status', 'woocommerce-pos' ), 'public' => true, 'exclude_from_search' => false, 'show_in_admin_all_list' => true, 'show_in_admin_status_list' => true, // translators: %s is the number of orders with POS - Partial Payment status. 'label_count' => _n_noop( 'POS - Partial Payment (%s)', 'POS - Partial Payment (%s)', 'woocommerce-pos' ), ) ); } /** * Set SKU on a synthetic product, bypassing WooCommerce's uniqueness check. * * Synthetic products (product_id=0) are never saved to the database, so * SKU collisions with real products are irrelevant. Temporarily disabling * object_read causes set_sku() to skip the wc_product_has_unique_sku() call. * * @param WC_Product_Simple $product Synthetic product instance. * @param string $sku SKU value from order-item meta. */ private function set_synthetic_product_sku( WC_Product_Simple $product, string $sku ): void { $product->set_object_read( false ); $product->set_sku( $sku ); $product->set_object_read( true ); } }