* * @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_payment_successful_result', array( $this, 'apply_unpaid_gateway_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; } /** * Apply the configured POS order status when a gateway settles without payment. * * The generic form of offline_process_payment_order_status(). Those three * gateways are hooked by name only because BACS, cheque and COD each expose a * `woocommerce_{id}_process_payment_order_status` filter. A gateway that takes * no money at the till and exposes no such filter — a quote, invoice or * purchase-order gateway — returned success while leaving the order at * pos-open, so the configured status was never applied and the till never * finished the sale. `woocommerce_payment_successful_result` is the seam every * gateway passes through: WooCommerce applies it after any successful * process_payment(), including on the POS pay page. * * Deliberately narrow, because "returned success but left the order open" is * also what a gateway awaiting an async confirmation looks like: * * - `pos-open` only, never `pos-partial` — a partial tender is still owed * money, and closing it would lose that. * - no `date_paid` — money moving means the payment_complete path owns the * status. * - the gateway must be enabled for POS *and* carry an explicitly stored * status. The settings view synthesizes `wc-completed` for every installed * gateway it has never seen, so trusting the computed value would mark an * unconfigured third-party gateway Completed with no money taken. * * @param array $result Gateway result, passed through untouched. * @param int $order_id Order ID. * * @return array */ public function apply_unpaid_gateway_order_status( $result, $order_id ) { if ( ! woocommerce_pos_request() ) { return $result; } $order = wc_get_order( $order_id ); if ( ! $order instanceof WC_Order || ! woocommerce_pos_is_pos_order( $order ) ) { return $result; } if ( ! $order->has_status( 'pos-open' ) || $order->get_date_paid( 'edit' ) ) { return $result; } $gateway_id = $order->get_payment_method(); $configured = $this->get_stored_gateway_order_status( $gateway_id ); if ( '' === $configured ) { return $result; } $status = $this->normalize_status( $configured, '' ); if ( '' === $status || $order->has_status( $status ) ) { return $result; } /* * payment_complete_order_status() reports the configured status as this * order's paid status, which makes WC_Order::set_status() stamp date_paid * the moment the status changes — booking an unpaid order as revenue. No * payment was taken here, so suppress it for this transition only. * * Scoped to this order id: a status-transition handler can call * payment_complete() on a *different* order while this filter is live * (subscriptions, bundles and gift-card plugins all do), and an * unconditional '' would reach that order too — set_status() rejects an * unknown status and falls back to 'pending', leaving an order that was * just paid sitting unpaid. */ $target_id = $order->get_id(); $suppress_paid_date = static function ( $payment_status, $filtered_order_id ) use ( $target_id ) { return (int) $filtered_order_id === $target_id ? '' : $payment_status; }; add_filter( 'woocommerce_payment_complete_order_status', $suppress_paid_date, PHP_INT_MAX, 2 ); try { /* * update_status()'s return value is deliberately not checked. It reports * false only when the order has no id — impossible here — because * WC_Abstract_Order::save() and WC_Order::status_transition() each catch * Exception themselves and handle_exception() does not rethrow, so a * throwing hook never reaches update_status()'s own catch and it still * returns true. Nor could the checkout be aborted from here: * WC_Form_Handler::pay_action() applies this filter inside its * `'success' === $result['result']` branch and redirects unconditionally * on the next line. */ $order->update_status( $status, /* translators: %s: payment gateway title. */ sprintf( __( 'Order status set by %s; no payment was taken at the till.', 'woocommerce-pos' ), $order->get_payment_method_title() ) ); } finally { // Must come off even if a status-change handler throws: left in place it // would suppress the configured status, and date_paid, for every later // payment in this request. remove_filter( 'woocommerce_payment_complete_order_status', $suppress_paid_date, PHP_INT_MAX ); } return $result; } /** * Read the explicitly stored per-gateway order status. * * Reads the raw options rather than the settings service, because the service * rebuilds its view from the installed gateways and synthesizes a default * status for gateways the merchant has never configured. Only a status the * merchant actually chose, on a gateway they enabled for POS, counts as intent. * * Two places hold such a choice, matching Payment_Gateways_Section::read(): * the per-gateway entry, and — on sites upgraded from before per-gateway * statuses — the legacy global `checkout.order_status`, which that section * still applies in memory to any gateway with no explicit status of its own * until the merchant next saves. * * @param string $gateway_id The payment gateway ID. * * @return string The stored status (may include the wc- prefix), or '' when absent. */ private function get_stored_gateway_order_status( string $gateway_id ): string { if ( '' === $gateway_id ) { return ''; } $stored = get_option( 'woocommerce_pos_settings_payment_gateways', array() ); if ( ! \is_array( $stored ) || ! isset( $stored['gateways'][ $gateway_id ] ) || ! \is_array( $stored['gateways'][ $gateway_id ] ) ) { return ''; } $gateway = $stored['gateways'][ $gateway_id ]; if ( ! isset( $gateway['enabled'] ) || ! wc_string_to_bool( $gateway['enabled'] ) ) { return ''; } if ( isset( $gateway['order_status'] ) && \is_string( $gateway['order_status'] ) && '' !== $gateway['order_status'] ) { return $gateway['order_status']; } return $this->get_legacy_checkout_order_status(); } /** * Read the legacy global checkout order status. * * Pre-dates per-gateway statuses. Payment_Gateways_Section::read() still seeds * it in memory for gateways with no explicit status, and leaves the key in * place until the merchant saves, so an upgraded site can have an enabled * gateway whose only configured status lives here. * * @return string The stored legacy status, or '' when absent. */ private function get_legacy_checkout_order_status(): string { $checkout = get_option( 'woocommerce_pos_settings_checkout', array() ); return \is_array( $checkout ) && isset( $checkout['order_status'] ) && \is_string( $checkout['order_status'] ) ? $checkout['order_status'] : ''; } /** * 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 ); } }