repo = $repo ? $repo : new PriceRevisionRepository(); } /** * Resolve sale confidence for an order. * * @param \WC_Order $order Order. * @return array{has_sale:bool,confidence:string,evidence:array} */ public function resolve( $order ) { $created = $order->get_date_created(); $at_gmt = $created ? gmdate( 'Y-m-d H:i:s', $created->getTimestamp() ) : gmdate( 'Y-m-d H:i:s' ); $has_sale = false; $any_verified = false; $any_suspected = false; $evidence = array(); foreach ( $order->get_items( 'line_item' ) as $item ) { $product_id = (int) $item->get_product_id(); $variation_id = (int) $item->get_variation_id(); $product = $item->get_product(); $revision = $this->repo->find_covering( $product_id, $variation_id, $at_gmt ); $on_sale_now = $product && method_exists( $product, 'is_on_sale' ) && $product->is_on_sale(); $line_suggests = $this->line_suggests_sale( $item, $product, $revision ); if ( $revision && PriceRevisionWriter::revision_proves_sale( $revision, $at_gmt ) ) { $has_sale = true; $any_verified = true; $evidence[] = array( 'product_id' => $product_id, 'variation_id' => $variation_id, 'confidence' => 'verified', 'revision_id' => (int) $revision['id'], 'revision_hash' => $revision['price_revision_hash'], 'continuity' => $revision['continuity_state'], ); continue; } // Catalog / line / gap revision — visible as sale but not Safe-eligible. $revision_has_sale = $revision && ( null !== $revision['sale_price'] && '' !== (string) $revision['sale_price'] ); if ( $on_sale_now || $revision_has_sale || $line_suggests ) { $has_sale = true; $any_suspected = true; $reason = 'no_revision_observation'; if ( $revision_has_sale || ( $revision && $on_sale_now ) ) { $reason = 'gap_or_schedule_mismatch'; } elseif ( $line_suggests ) { $reason = 'line_price_below_regular'; } elseif ( $on_sale_now ) { $reason = 'catalog_on_sale_unverified'; } $evidence[] = array( 'product_id' => $product_id, 'variation_id' => $variation_id, 'confidence' => 'suspected', 'revision_id' => $revision ? (int) $revision['id'] : null, 'revision_hash' => $revision ? $revision['price_revision_hash'] : null, 'continuity' => $revision ? $revision['continuity_state'] : null, 'reason' => $reason, ); } } if ( ! $has_sale ) { return array( 'has_sale' => false, 'confidence' => 'none', 'evidence' => $evidence, ); } // All sale lines must be verified for overall verified; any suspected wins. $confidence = ( $any_verified && ! $any_suspected ) ? 'verified' : 'suspected'; return array( 'has_sale' => true, 'confidence' => $confidence, 'evidence' => $evidence, ); } /** * Heuristic: unit subtotal (pre-coupon) below known regular price. * * @param \WC_Order_Item_Product $item Line item. * @param \WC_Product|false|null $product Product. * @param array|null $revision Covering revision. * @return bool */ private function line_suggests_sale( $item, $product, $revision ) { $qty_norm = QuantityNormalizer::parse( (string) $item->get_quantity() ); if ( ! $qty_norm['valid'] || $qty_norm['units'] <= 0 || ! is_callable( array( $item, 'get_subtotal' ) ) ) { return false; } $qty = $qty_norm['units']; $regular_amount = null; if ( $revision && null !== $revision['regular_price'] && '' !== (string) $revision['regular_price'] ) { try { $regular_amount = MoneyAmount::from_decimal_string( (string) $revision['regular_price'], 6 ); } catch ( MoneyException $e ) { $regular_amount = null; } } elseif ( $product && is_callable( array( $product, 'get_regular_price' ) ) ) { $rp = $product->get_regular_price(); if ( '' !== $rp && null !== $rp ) { try { $regular_amount = MoneyAmount::from_decimal_string( (string) $rp, 6 ); } catch ( MoneyException $e ) { $regular_amount = null; } } } if ( null === $regular_amount || $regular_amount->is_zero() ) { return false; } // PRD 9.7: no float — compare integer units directly. // Unit check: line_subtotal / qty < regular // Multiplied to avoid fractional division: // line_subtotal_units < regular_units * qty // We allow a tiny noise tolerance by subtracting 1 (covers rounding at scale 6). try { $line_subtotal_amount = MoneyAmount::from_decimal_string( (string) $item->get_subtotal(), 6 ); if ( null === $line_subtotal_amount ) { return false; } } catch ( MoneyException $e ) { return false; } $threshold = bcmul( (string) $regular_amount->units(), (string) $qty, 0 ); $line = (string) $line_subtotal_amount->units(); // One-unit tolerance: rounding noise at scale 6 is not a sale signal. $gap = bcsub( $threshold, $line, 0 ); return bccomp( $gap, '1', 0 ) > 0; } }