parse_amount( (string) $manual, 'manual', 'exact' ); } /** * Whether manual cost meta is present (non-empty). * * @param int $product_id Product ID. * @return bool */ public function has_manual( $product_id ) { $manual = get_post_meta( (int) $product_id, Constants::META_MANUAL_COST, true ); return '' !== $manual && null !== $manual; } /** * Read native WooCommerce COGS with presence semantics (null ≠ ambiguous 0 ≠ explicit 0). * * @param \WC_Product|null|false $product Product. * @return CostResolution|null Null when clearly absent; ambiguous_zero / resolved otherwise. */ public function read_native( $product ) { if ( ! $product ) { return null; } if ( is_callable( array( $product, 'get_cogs_total_value' ) ) ) { $value = $product->get_cogs_total_value(); if ( null !== $value && '' !== $value && is_numeric( $value ) ) { try { $amount = MoneyAmount::from_decimal_string( (string) $value, Constants::DEFAULT_MONEY_SCALE ); if ( null !== $amount && ! $amount->is_negative() ) { if ( $amount->is_zero() ) { if ( is_callable( array( $product, 'has_cogs_value' ) ) && ! $product->has_cogs_value() ) { return null; } return CostResolution::ambiguous_zero(); } return new CostResolution( $amount, 'native_cogs', 'exact', 'explicit' ); } } catch ( MoneyException $e ) { return new CostResolution( null, 'native_cogs', 'missing', 'ambiguous', $e->error_code() ); } } } if ( is_callable( array( $product, 'get_cogs_value' ) ) ) { $value = $product->get_cogs_value(); if ( null === $value || '' === $value ) { return null; } if ( is_numeric( $value ) && preg_match( '/^-?0(?:\.0+)?$/', trim( (string) $value ) ) ) { if ( is_callable( array( $product, 'has_cogs_value' ) ) && ! $product->has_cogs_value() ) { return null; } if ( ! is_callable( array( $product, 'has_cogs_value' ) ) ) { return CostResolution::ambiguous_zero(); } } try { $amount = MoneyAmount::from_decimal_string( (string) $value, Constants::DEFAULT_MONEY_SCALE ); if ( null === $amount ) { return null; } if ( $amount->is_negative() ) { return new CostResolution( null, 'native_cogs', 'missing', 'explicit', 'MB_COST_INVALID' ); } return new CostResolution( $amount, 'native_cogs', 'exact', 'explicit' ); } catch ( MoneyException $e ) { return new CostResolution( null, 'native_cogs', 'missing', 'ambiguous', $e->error_code() ); } } $meta = is_callable( array( $product, 'get_meta' ) ) ? $product->get_meta( '_cogs_total_value', true ) : get_post_meta( $product->get_id(), '_cogs_total_value', true ); if ( '' === $meta || null === $meta ) { return null; } try { $amount = MoneyAmount::from_decimal_string( (string) $meta, Constants::DEFAULT_MONEY_SCALE ); if ( null === $amount ) { return null; } if ( $amount->is_zero() ) { return CostResolution::ambiguous_zero(); } return new CostResolution( $amount, 'native_cogs_meta', 'exact', 'explicit' ); } catch ( MoneyException $e ) { return CostResolution::missing( $e->error_code() ); } } /** * Whether native COGS is an Exact resolved value (not missing, not ambiguous). * * @param \WC_Product|null|false $product Product. * @return bool */ public function has_resolved_native( $product ) { $native = $this->read_native( $product ); return null !== $native && $native->is_resolved(); } /** * Write MarginBeacon legacy cost. Empty string clears. Does not touch Woo COGS. * * @param int $product_id Product ID. * @param string $raw Validated decimal or empty. * @return bool */ public function write_manual( $product_id, $raw ) { $product_id = (int) $product_id; $raw = trim( (string) $raw ); $previous = (string) get_post_meta( $product_id, Constants::META_MANUAL_COST, true ); if ( '' === $raw ) { if ( '' === $previous ) { return true; } delete_post_meta( $product_id, Constants::META_MANUAL_COST ); delete_post_meta( $product_id, Constants::META_MANUAL_COST_UPDATED ); ( new StalenessObserver() )->on_manual_cost_change( $product_id ); return true; } if ( '.' === $raw[0] ) { $raw = '0' . $raw; } $formatted = function_exists( 'wc_format_decimal' ) ? (string) wc_format_decimal( $raw ) : $raw; if ( $formatted === $previous ) { return true; } if ( false === update_post_meta( $product_id, Constants::META_MANUAL_COST, $formatted ) ) { return false; } update_post_meta( $product_id, Constants::META_MANUAL_COST_UPDATED, gmdate( 'Y-m-d H:i:s' ) ); ( new StalenessObserver() )->on_manual_cost_change( $product_id ); return true; } /** * Write WooCommerce native COGS. Does not overwrite when $only_if_missing and value exists. * Does not delete/clear MarginBeacon legacy meta. * * WooCommerce core converts set_cogs_value(0.0) to null (Missing). Explicit zero therefore * cannot be preserved through the official API and returns requires_review without writing. * * @param int $product_id Product ID. * @param string|null $decimal Decimal string; empty/null clears when not $only_if_missing. * @param bool $only_if_missing Skip when Woo already has a resolved value. * @return string migrated|same|conflicts|skipped|failed|requires_review */ public function write_native( $product_id, $decimal, $only_if_missing = false ) { $product_id = (int) $product_id; $product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null; if ( ! $product ) { return 'failed'; } $existing = $this->read_native( $product ); if ( $only_if_missing && null !== $existing && $existing->is_resolved() ) { if ( null !== $decimal && '' !== (string) $decimal ) { try { $incoming = MoneyAmount::from_decimal_string( (string) $decimal, Constants::DEFAULT_MONEY_SCALE ); if ( null !== $incoming && null !== $existing->amount() && $incoming->units() === $existing->amount()->units() ) { return 'same'; } } catch ( MoneyException $e ) { return 'failed'; } return 'conflicts'; } return 'skipped'; } if ( null === $decimal || '' === trim( (string) $decimal ) ) { if ( $only_if_missing ) { return 'skipped'; } return $this->clear_native( $product_id, $product ); } try { $amount = MoneyAmount::from_decimal_string( (string) $decimal, Constants::DEFAULT_MONEY_SCALE ); if ( null === $amount || $amount->is_negative() ) { return 'failed'; } } catch ( MoneyException $e ) { return 'failed'; } // Official Woo API collapses 0.0 → null (Missing). Do not write or claim migrated. if ( $amount->is_zero() ) { return 'requires_review'; } $decimal_s = $amount->to_decimal_string(); if ( is_callable( array( $product, 'set_cogs_value' ) ) ) { $product->set_cogs_value( (float) $decimal_s ); $product->save(); return $this->verify_native_write( $product_id, $amount ); } if ( false === update_post_meta( $product_id, '_cogs_total_value', $decimal_s ) ) { $current = (string) get_post_meta( $product_id, '_cogs_total_value', true ); if ( $current === $decimal_s ) { return $this->verify_native_write( $product_id, $amount ); } return 'failed'; } return $this->verify_native_write( $product_id, $amount ); } /** * Clear WooCommerce native COGS via the official API when available. * * @param int $product_id Product ID. * @param \WC_Product|null $product Optional loaded product. * @return string migrated|same|failed */ public function clear_native( $product_id, $product = null ) { $product_id = (int) $product_id; if ( ! $product ) { $product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null; } if ( ! $product ) { return 'failed'; } $before = $this->read_native( $product ); if ( null === $before || ! $before->is_resolved() ) { // Already Missing / ambiguous — treat clear as success without inventing presence. if ( is_callable( array( $product, 'set_cogs_value' ) ) ) { $product->set_cogs_value( null ); $product->save(); } else { delete_post_meta( $product_id, '_cogs_total_value' ); } $after = $this->read_native( function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : $product ); if ( null !== $after && $after->is_resolved() ) { return 'failed'; } return 'same'; } if ( is_callable( array( $product, 'set_cogs_value' ) ) ) { $product->set_cogs_value( null ); $product->save(); } else { delete_post_meta( $product_id, '_cogs_total_value' ); } $fresh = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : $product; $after = $this->read_native( $fresh ); if ( null !== $after && $after->is_resolved() ) { return 'failed'; } return 'migrated'; } /** * Semantic read-after-write: MoneyAmount units must match; Missing/ambiguous is failure. * * @param int $product_id Product ID. * @param MoneyAmount $expected Expected amount. * @return string migrated|failed */ private function verify_native_write( $product_id, MoneyAmount $expected ) { $fresh = function_exists( 'wc_get_product' ) ? wc_get_product( (int) $product_id ) : null; $native = $this->read_native( $fresh ); if ( null === $native || ! $native->is_resolved() || ! $native->amount() ) { return 'failed'; } if ( $native->amount()->units() !== $expected->units() ) { return 'failed'; } return 'migrated'; } /** * Write to the active canonical store. * * @param int $product_id Product ID. * @param string $raw Validated non-negative decimal; empty clears the canonical store. * @return bool */ public function write_canonical( $product_id, $raw ) { $source = ( new CanonicalCostSource() )->get(); if ( CanonicalCostSource::WOOCOMMERCE === $source ) { $result = $this->write_native( (int) $product_id, '' === trim( (string) $raw ) ? null : (string) $raw, false ); return in_array( $result, array( 'migrated', 'same' ), true ); } return $this->write_manual( (int) $product_id, (string) $raw ); } /** * Apply a bulk adjustment against an existing resolved cost. * * @param string $existing_decimal Existing cost decimal. * @param string $operation change|increase_fixed|decrease_fixed|increase_percent|decrease_percent. * @param string $operand Operand decimal. * @return string|null New decimal or null when invalid. */ public function apply_adjustment( $existing_decimal, $operation, $operand ) { if ( ! function_exists( 'bcadd' ) || ! function_exists( 'bcmul' ) || ! function_exists( 'bcdiv' ) || ! function_exists( 'bccomp' ) ) { return null; } $existing = trim( (string) $existing_decimal ); $operand = trim( (string) $operand ); if ( '' === $operand || ! preg_match( '/^\d+(?:\.\d+)?$/', $operand ) ) { return null; } switch ( $operation ) { case 'change': $result = bcadd( $operand, '0', 6 ); break; case 'increase_fixed': $result = bcadd( $existing, $operand, 6 ); break; case 'decrease_fixed': $result = bcsub( $existing, $operand, 6 ); break; case 'increase_percent': $delta = bcdiv( bcmul( $existing, $operand, 8 ), '100', 6 ); $result = bcadd( $existing, $delta, 6 ); break; case 'decrease_percent': $delta = bcdiv( bcmul( $existing, $operand, 8 ), '100', 6 ); $result = bcsub( $existing, $delta, 6 ); break; default: return null; } if ( bccomp( $result, '0', 6 ) < 0 ) { $result = '0.000000'; } return $result; } /** * @param string $value Decimal string. * @param string $source Source key. * @param string $confidence Confidence. * @return CostResolution */ public function parse_amount( $value, $source, $confidence ) { try { $amount = MoneyAmount::from_decimal_string( $value, Constants::DEFAULT_MONEY_SCALE ); if ( null === $amount ) { return CostResolution::missing(); } if ( $amount->is_negative() ) { return new CostResolution( null, $source, 'missing', 'explicit', 'MB_COST_INVALID' ); } return new CostResolution( $amount, $source, $confidence, 'explicit' ); } catch ( MoneyException $e ) { return new CostResolution( null, $source, 'missing', 'explicit', $e->error_code() ); } } }