fallback = $fallback instanceof DiscountAllocatorInterface ? $fallback : new CouponDiscountAllocator(); } /** * Whether native Woo discount engine is available. * * @return bool */ public function is_available() { return class_exists( '\WC_Discounts' ) && function_exists( 'wc_add_number_precision' ) && function_exists( 'wc_remove_number_precision' ); } /** * Allocate one coupon across synthetic lines. * * Each line: key (string), product (WC_Product), quantity (int), unit_price (MoneyAmount tax-exclusive). * * @param \WC_Coupon $coupon Coupon. * @param array $lines Synthetic lines. * @return array{ * fidelity:string, * discounts:array, * note:?string * } */ public function allocate_coupon_to_lines( $coupon, array $lines ) { if ( empty( $lines ) ) { return array( 'fidelity' => 'native', 'discounts' => array(), 'note' => 'empty_lines', ); } // Validate coupon constraints before native OR fallback — never approximate invalid coupons. $scale = Constants::DEFAULT_MONEY_SCALE; foreach ( $lines as $line ) { if ( ! empty( $line['unit_price'] ) && $line['unit_price'] instanceof MoneyAmount ) { $scale = $line['unit_price']->scale(); break; } } $subtotal = $this->synthetic_subtotal( $lines, $scale ); $validator = new CouponSimulationValidator(); $check = $validator->validate_for_simulation( $coupon, $subtotal ); if ( empty( $check['valid'] ) ) { return $this->rejected_lines( $lines, 'sim_validation:' . (string) $check['code'] ); } if ( ! $this->is_available() ) { return $this->fallback_lines( $coupon, $lines, 'woo_discounts_unavailable' ); } try { $items = $this->build_discount_items( $lines ); if ( empty( $items ) ) { return array( 'fidelity' => 'native', 'discounts' => array(), 'note' => 'no_priced_lines', ); } $engine = new \WC_Discounts(); // No cart/order object → no live session coupling. $engine->set_items( $items ); // validate=true: expiry, min/max spend, product restrictions (not usage limits). $applied = $engine->apply_coupon( $coupon, true ); if ( is_wp_error( $applied ) ) { $code = $applied->get_error_code(); // Config/validity failures must not fall back to approximated discounts. if ( $this->is_hard_invalid_coupon_error( $code ) ) { return $this->rejected_lines( $lines, 'woo_apply_error:' . $code ); } // Soft limits (usage count / email) may still be simulated for preflight. $applied = $engine->apply_coupon( $coupon, false ); if ( is_wp_error( $applied ) ) { return $this->rejected_lines( $lines, 'woo_apply_error:' . $applied->get_error_code() ); } } $discounts = array(); foreach ( array_keys( $items ) as $key ) { $raw = $engine->get_discount( $key, false ); $discounts[ $key ] = $this->money_from_woo_amount( $raw, $scale ); } return array( 'fidelity' => 'native', 'discounts' => $discounts, 'note' => null, ); } catch ( MoneyException $e ) { return $this->rejected_lines( $lines, $e->error_code() ); } catch ( \Throwable $e ) { return $this->fallback_lines( $coupon, $lines, 'woo_exception:' . $e->getMessage() ); } } /** * Single-line convenience (Free selected-coupon unit qty scenario). * * @param \WC_Coupon $coupon Coupon. * @param \WC_Product $product Product/variation. * @param MoneyAmount $unit_price Tax-exclusive effective unit price. * @param int $quantity Quantity. * @return array{discount:MoneyAmount,fidelity:string,note:?string} */ public function allocate_single_line( $coupon, $product, MoneyAmount $unit_price, $quantity ) { $qty = max( 0, (int) $quantity ); if ( $qty <= 0 ) { return array( 'discount' => MoneyAmount::zero( $unit_price->scale() ), 'fidelity' => $this->is_available() ? 'native' : 'approximated', 'note' => 'zero_quantity', ); } $line_subtotal = $this->multiply_unit_by_qty( $unit_price, $qty ); $result = $this->allocate_coupon_to_lines( $coupon, array( array( 'key' => 'line_0', 'product' => $product, 'quantity' => $qty, 'unit_price' => $unit_price, ), ) ); $discount = isset( $result['discounts']['line_0'] ) ? $result['discounts']['line_0'] : MoneyAmount::zero( $unit_price->scale() ); // Cap: never exceed line subtotal (Woo should already, belt-and-suspenders). if ( MoneyMath::compare( $discount, $line_subtotal ) > 0 ) { $discount = $line_subtotal; } return array( 'discount' => $discount, 'fidelity' => $result['fidelity'], 'note' => $result['note'], ); } /** * @param array $lines Lines. * @return array */ protected function build_discount_items( array $lines ) { $items = array(); foreach ( $lines as $line ) { $key = (string) $line['key']; $product = $line['product']; $quantity = max( 0, (int) $line['quantity'] ); /** @var MoneyAmount $unit */ $unit = $line['unit_price']; if ( $quantity <= 0 || ! $product ) { continue; } $line_subtotal = $this->multiply_unit_by_qty( $unit, $quantity ); $item = new \stdClass(); $item->key = $key; $item->product = $product; $item->quantity = $quantity; // PRD 9.7: no float cast — money passed as string/integer to WC integer precision layer. $item->price = wc_add_number_precision( $line_subtotal->to_decimal_string() ); $item->object = array( 'data' => $product, 'quantity' => $quantity, 'line_subtotal' => $line_subtotal->to_decimal_string(), 'line_total' => $line_subtotal->to_decimal_string(), ); $items[ $key ] = $item; } return $items; } /** * Sum synthetic line subtotals for coupon min/max validation. * * @param array $lines Synthetic lines. * @param int $scale Money scale. * @return MoneyAmount */ protected function synthetic_subtotal( array $lines, $scale ) { $total = MoneyAmount::zero( $scale ); foreach ( $lines as $line ) { $qty = max( 0, (int) $line['quantity'] ); if ( $qty <= 0 || empty( $line['unit_price'] ) || ! $line['unit_price'] instanceof MoneyAmount ) { continue; } $total = MoneyMath::add( $total, $this->multiply_unit_by_qty( $line['unit_price'], $qty ) ); } return $total; } /** * Zero discounts when coupon is invalid — do not approximate invalid promotions. * * @param array $lines Synthetic lines. * @param string $note Rejection reason. * @return array{fidelity:string,discounts:array,note:string} */ protected function rejected_lines( array $lines, $note ) { $scale = Constants::DEFAULT_MONEY_SCALE; $discounts = array(); foreach ( $lines as $line ) { if ( ! empty( $line['unit_price'] ) && $line['unit_price'] instanceof MoneyAmount ) { $scale = $line['unit_price']->scale(); } $discounts[ (string) $line['key'] ] = MoneyAmount::zero( $scale ); } return array( 'fidelity' => 'rejected', 'discounts' => $discounts, 'note' => (string) $note, ); } /** * Woo / simulation errors that mean the coupon must not be applied. * * @param string $code Error code. * @return bool */ protected function is_hard_invalid_coupon_error( $code ) { return in_array( (string) $code, array( 'invalid_coupon', 'invalid_coupon_expired', 'invalid_coupon_minimum_amount', 'invalid_coupon_maximum_amount', 'invalid_coupon_excluded_product', 'invalid_coupon_excluded_product_categories', 'invalid_coupon_product_categories', 'invalid_coupon_product_ids', 'invalid_coupon_sale_items', 'coupon_not_exist', ), true ); } /** * @param \WC_Coupon $coupon Coupon. * @param array $lines Lines. * @param string $note Fallback reason. * @return array */ protected function fallback_lines( $coupon, array $lines, $note ) { $payload = array(); foreach ( $lines as $line ) { $key = (string) $line['key']; $quantity = max( 0, (int) $line['quantity'] ); /** @var MoneyAmount $unit */ $unit = $line['unit_price']; $line_subtotal = $this->multiply_unit_by_qty( $unit, $quantity ); $product = isset( $line['product'] ) ? $line['product'] : null; $product_id = 0; $variation_id = 0; if ( $product && is_object( $product ) ) { $is_variation = method_exists( $product, 'is_type' ) && $product->is_type( 'variation' ); $variation_id = $is_variation ? (int) $product->get_id() : 0; $product_id = $is_variation ? (int) $product->get_parent_id() : (int) $product->get_id(); } $payload[] = array( 'key' => $key, 'line_subtotal' => $line_subtotal, 'quantity' => $quantity, 'product_id' => $product_id, 'variation_id' => $variation_id, ); } $result = $this->fallback->allocate_lines( $coupon, $payload ); $fidelity = ( isset( $result['fidelity'] ) && 'rejected' === $result['fidelity'] ) ? 'rejected' : 'approximated'; return array( 'fidelity' => $fidelity, 'discounts' => $result['discounts'], 'note' => $note . ( $result['note'] ? ( '|' . $result['note'] ) : '' ), ); } /** * @param mixed $raw Woo amount (float/string). * @param int $scale Money scale. * @return MoneyAmount * @throws MoneyException When the amount cannot be parsed losslessly. */ protected function money_from_woo_amount( $raw, $scale ) { if ( function_exists( 'wc_format_decimal' ) ) { $string = wc_format_decimal( $raw, $scale ); } else { $string = $this->format_decimal_string( (string) $raw, $scale ); } if ( null === $string || '' === trim( (string) $string ) ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'empty woo money amount' ); } $amount = MoneyAmount::from_decimal_string( (string) $string, $scale ); if ( null === $amount ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'missing woo money amount' ); } return $amount; } /** * Format a decimal string to fixed scale without floating point. * * Rejects scientific notation, multiple separators, and non-numeric input. * Does not silently truncate non-zero fractional digits beyond scale. * * @param string $raw Raw decimal string. * @param int $scale Target decimal places. * @return string * @throws MoneyException On malformed input. */ protected function format_decimal_string( $raw, $scale ) { $raw = trim( (string) $raw ); if ( '' === $raw ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'empty decimal string' ); } if ( ! preg_match( '/^-?\d+(?:\.\d+)?$/', $raw ) ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'malformed decimal string' ); } $neg = false; if ( '-' === $raw[0] ) { $neg = true; $raw = substr( $raw, 1 ); } $parts = explode( '.', $raw, 2 ); $whole = ltrim( $parts[0], '0' ); if ( '' === $whole ) { $whole = '0'; } $frac = isset( $parts[1] ) ? $parts[1] : ''; if ( strlen( $frac ) > $scale && preg_match( '/[^0]/', substr( $frac, $scale ) ) ) { throw new MoneyException( 'MB_MONEY_PRECISION_INVALID', 'value exceeds money_scale losslessly' ); } $frac = str_pad( substr( $frac, 0, $scale ), $scale, '0', STR_PAD_RIGHT ); return ( $neg ? '-' : '' ) . $whole . '.' . $frac; } /** * @param MoneyAmount $unit Unit. * @param int $qty Qty. * @return MoneyAmount */ protected function multiply_unit_by_qty( MoneyAmount $unit, $qty ) { return MoneyMath::multiply_by_integer( $unit, (int) $qty ); } }