store = $store ? $store : new ProductCostStore(); $this->canonical = $canonical ? $canonical : new CanonicalCostSource(); } /** * Resolve catalog cost for a product or variation. * * @param int $product_id Product ID. * @param int $variation_id Variation ID (0 for simple). * @return CostResolution */ public function resolve_catalog( $product_id, $variation_id = 0 ) { $cost_settings = get_option( Constants::OPTION_COST_SETTINGS, array() ); if ( ! is_array( $cost_settings ) ) { $cost_settings = array(); } if ( ! empty( $cost_settings['cogs_tax_basis'] ) && 'inclusive' === $cost_settings['cogs_tax_basis'] ) { return new CostResolution( null, 'merchant_declared', 'missing', 'missing', 'MB_COGS_TAX_BASIS_INVALID' ); } $target_id = $variation_id > 0 ? $variation_id : $product_id; $source = $this->canonical->get(); if ( CanonicalCostSource::WOOCOMMERCE === $source ) { return $this->resolve_woocommerce_catalog( $target_id ); } return $this->resolve_marginbeacon_catalog( $product_id, $variation_id, $target_id, $cost_settings ); } /** * Resolve cost for an order line. * * Historical order-item native snapshots are used only when canonical is WooCommerce. * Findings already persisted keep their historical evidence untouched. * * @param mixed $item WC_Order_Item_Product. * @param int $product_id Product ID. * @param int $variation_id Variation ID. * @return CostResolution */ public function resolve_for_order_item( $item, $product_id, $variation_id = 0 ) { $cost_settings = get_option( Constants::OPTION_COST_SETTINGS, array() ); if ( ! is_array( $cost_settings ) ) { $cost_settings = array(); } if ( ! empty( $cost_settings['cogs_tax_basis'] ) && 'inclusive' === $cost_settings['cogs_tax_basis'] ) { return new CostResolution( null, 'merchant_declared', 'missing', 'missing', 'MB_COGS_TAX_BASIS_INVALID' ); } if ( CanonicalCostSource::WOOCOMMERCE === $this->canonical->get() ) { if ( $item && is_object( $item ) && ( new CogsEnvironment() )->native_cogs_feature_enabled() ) { $from_item = $this->read_order_item_cogs( $item ); if ( null !== $from_item ) { return $from_item; } } } return $this->resolve_catalog( $product_id, $variation_id ); } /** * @param int $target_id Product or variation ID. * @return CostResolution */ private function resolve_woocommerce_catalog( $target_id ) { if ( ! ( new CogsEnvironment() )->native_cogs_feature_enabled() ) { return new CostResolution( null, 'native_cogs', 'missing', 'missing', 'MB_COST_SOURCE_UNAVAILABLE' ); } $product = function_exists( 'wc_get_product' ) ? wc_get_product( $target_id ) : null; $native = $this->store->read_native( $product ); if ( null === $native ) { return CostResolution::missing(); } if ( ! $native->is_resolved() ) { // Ambiguous zero / invalid — Missing, never fall back to MarginBeacon legacy. return $native; } return $native; } /** * @param int $product_id Parent product ID. * @param int $variation_id Variation ID. * @param int $target_id Target ID for meta. * @param array $cost_settings Cost settings. * @return CostResolution */ private function resolve_marginbeacon_catalog( $product_id, $variation_id, $target_id, array $cost_settings ) { $manual = $this->store->read_manual( $target_id ); if ( null !== $manual ) { return $manual; } $meta_key = isset( $cost_settings['custom_meta_key'] ) ? sanitize_key( (string) $cost_settings['custom_meta_key'] ) : ''; if ( '' !== $meta_key ) { $custom = get_post_meta( $target_id, $meta_key, true ); if ( '' === $custom || null === $custom ) { $parent_id = $variation_id > 0 ? $product_id : 0; if ( $parent_id ) { $custom = get_post_meta( $parent_id, $meta_key, true ); } } if ( '' !== $custom && null !== $custom ) { return $this->store->parse_amount( (string) $custom, 'custom_meta', 'exact' ); } } if ( ! empty( $cost_settings['fallback_enabled'] ) ) { $product = function_exists( 'wc_get_product' ) ? wc_get_product( $target_id ) : null; if ( ( new EntitlementService() )->resolve()->can( 'category_assumed_cost' ) ) { $cat_rule = $this->resolve_category_assumed( $product, $product_id, $variation_id ); if ( null !== $cat_rule ) { return $cat_rule; } } $percent = isset( $cost_settings['global_assumed_cost_percent'] ) ? $cost_settings['global_assumed_cost_percent'] : null; if ( null !== $percent && '' !== $percent ) { $assumed = $this->resolve_assumed_percent( $product, $percent, 'global_assumed_percent' ); if ( null !== $assumed ) { return $assumed; } } if ( isset( $cost_settings['global_assumed_cost'] ) && null !== $cost_settings['global_assumed_cost'] && '' !== $cost_settings['global_assumed_cost'] ) { return $this->store->parse_amount( (string) $cost_settings['global_assumed_cost'], 'global_assumed', 'estimated' ); } } return CostResolution::missing(); } /** * @param \WC_Product|null|false $product Product. * @param int $product_id Product ID. * @param int $variation_id Variation ID. * @return CostResolution|null */ private function resolve_category_assumed( $product, $product_id, $variation_id ) { /** * Pro category assumed-cost rule. Free returns null without companion. * * @param array{percent:string,term_id:int}|null $picked Prior. * @param mixed $product Product. * @param int $product_id Product ID. * @param int $variation_id Variation ID. */ $picked = apply_filters( 'aksmb_resolve_category_assumed_cost', null, $product, $product_id, $variation_id ); if ( ! is_array( $picked ) || ! isset( $picked['percent'] ) ) { return null; } return $this->resolve_assumed_percent( $product, $picked['percent'], 'category_assumed_percent' ); } /** * @param \WC_Product|null|false $product Product. * @param mixed $percent Percent 0–100. * @param string $source Source key. * @return CostResolution|null */ private function resolve_assumed_percent( $product, $percent, $source = 'global_assumed_percent' ) { if ( ! $product ) { return null; } $percent_s = trim( (string) $percent ); if ( '' === $percent_s || ! preg_match( '/^\d+(?:\.\d+)?$/', $percent_s ) ) { return new CostResolution( null, $source, 'missing', 'explicit', 'MB_COST_INVALID' ); } if ( ! function_exists( 'bccomp' ) ) { return new CostResolution( null, $source, 'missing', 'explicit', 'MB_COST_INVALID' ); } if ( bccomp( $percent_s, '0', 6 ) < 0 || bccomp( $percent_s, '100', 6 ) > 0 ) { return new CostResolution( null, $source, 'missing', 'explicit', 'MB_COST_INVALID' ); } $regular = $product->get_regular_price(); if ( '' === $regular || null === $regular ) { return null; } try { $price = MoneyAmount::from_decimal_string( (string) $regular, Constants::DEFAULT_MONEY_SCALE ); if ( null === $price ) { return null; } $units = (string) $price->units(); $scaled = bcdiv( bcmul( $units, $percent_s, 8 ), '100', 0 ); $cost = new MoneyAmount( $scaled, $price->scale() ); return new CostResolution( $cost, $source, 'estimated', 'explicit' ); } catch ( MoneyException $e ) { return new CostResolution( null, $source, 'missing', 'explicit', $e->error_code() ); } } /** * Native WooCommerce order-item COGS snapshot (positive line totals only). * * @param object $item Order item. * @return CostResolution|null */ private function read_order_item_cogs( $item ) { if ( is_callable( array( $item, 'get_cogs_value' ) ) ) { $value = $item->get_cogs_value(); if ( null === $value || '' === $value || ! is_numeric( $value ) ) { return null; } $zero_or_less = function_exists( 'bccomp' ) ? bccomp( (string) $value, '0', 8 ) <= 0 : preg_match( '/^-?(?:0(?:\.0*)?|\.0+)$/', trim( (string) $value ) ) || preg_match( '/^-/', trim( (string) $value ) ); if ( $zero_or_less ) { return null; } try { $amount = MoneyAmount::from_decimal_string( (string) $value, Constants::DEFAULT_MONEY_SCALE ); if ( null === $amount || $amount->is_negative() || $amount->is_zero() ) { return null; } return new CostResolution( $amount, 'native_order_item_cogs', 'exact', 'explicit' ); } catch ( MoneyException $e ) { return null; } } if ( is_callable( array( $item, 'get_meta' ) ) ) { $meta = $item->get_meta( '_cogs_total_value', true ); if ( '' !== $meta && null !== $meta && is_numeric( $meta ) ) { $positive = function_exists( 'bccomp' ) ? bccomp( (string) $meta, '0', 8 ) > 0 : preg_match( '/^\+?0*\.?[1-9]/', ltrim( (string) $meta, '0' ) ) || preg_match( '/^[1-9]/', (string) $meta ); if ( ! $positive ) { return null; } try { $amount = MoneyAmount::from_decimal_string( (string) $meta, Constants::DEFAULT_MONEY_SCALE ); if ( null === $amount || $amount->is_negative() || $amount->is_zero() ) { return null; } return new CostResolution( $amount, 'native_order_item_cogs', 'exact', 'explicit' ); } catch ( MoneyException $e ) { return null; } } } return null; } }