<?php declare(strict_types = 1);
namespace MailPoet\WooCommerce;
if (!defined('ABSPATH')) exit;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use MailPoet\Entities\StatisticsWooCommercePurchaseEntity;
use MailPoet\Settings\TrackingConfig;
use MailPoet\Statistics\StatisticsWooCommercePurchasesRepository;
use MailPoet\WP\Functions as WPFunctions;
use WC_Order;
/**
* Dual-run reconciliation for the Woo attribution migration (STOMAIL-8136).
*
* Compares MailPoet's legacy purchase attribution (statistics_woocommerce_purchases
* rows) with the MailPoet attribution stored on the Woo order by OrderAttributionWriter
* and records a structured result as order meta. The record feeds the rollout gate
* ratified in STOMAIL-8135 (decision 8); it never changes reported revenue.
*/
class OrderAttributionReconciler {
const RECONCILIATION_META_KEY = '_mailpoet_attribution_reconciliation';
const RECORD_VERSION = 1;
const OUTCOME_MATCH = 'match';
const OUTCOME_DIVERGED = 'diverged';
const CLASSIFICATION_INTENTIONAL = 'intentional';
const CLASSIFICATION_UNEXPLAINED = 'unexplained';
const REASON_MATCHED = 'matched';
const REASON_NO_CLICK_CANDIDATE = 'no_click_candidate';
// Intentional divergences per the STOMAIL-8135 contract.
const REASON_LAST_CLICK_COLLAPSE = 'last_click_collapse';
const REASON_FOREIGN_SOURCE_PRESERVED = 'foreign_source_preserved';
// Unexplained unless proven otherwise.
const REASON_CANONICAL_CLICK_MISMATCH = 'canonical_click_mismatch';
const REASON_WOO_ATTRIBUTION_DISABLED = 'woo_attribution_disabled';
const REASON_TRACKING_DISABLED = 'tracking_disabled';
const REASON_WOO_ATTRIBUTION_MISSING = 'woo_attribution_missing';
const REASON_LEGACY_ATTRIBUTION_MISSING = 'legacy_attribution_missing';
const TRIGGER_STATUS_CHANGED = 'status_changed';
const TRIGGER_REFUND = 'refund';
/** @var WPFunctions */
private $wp;
/** @var Helper */
private $wooHelper;
/** @var TrackingConfig */
private $trackingConfig;
/** @var StatisticsWooCommercePurchasesRepository */
private $purchasesRepository;
public function __construct(
WPFunctions $wp,
Helper $wooHelper,
TrackingConfig $trackingConfig,
StatisticsWooCommercePurchasesRepository $purchasesRepository
) {
$this->wp = $wp;
$this->wooHelper = $wooHelper;
$this->trackingConfig = $trackingConfig;
$this->purchasesRepository = $purchasesRepository;
}
/**
* Runs on woocommerce_order_status_changed/woocommerce_order_refunded after both
* the legacy purchase tracker and the attribution writer (priority 10), so both
* attribution results are available and the legacy rows are synced to the live
* order status.
*
* @param int|WC_Order $order
*/
public function reconcileForOrder($order, string $trigger = self::TRIGGER_STATUS_CHANGED): void {
if (!$this->wooHelper->isWooCommerceActive()) {
return;
}
if (!$order instanceof WC_Order) {
$order = $this->wooHelper->wcGetOrder($order);
}
if (!$order instanceof WC_Order) {
return;
}
if (!$this->isAfterWritesStartedBoundary($order)) {
return;
}
$purchases = $this->getPurchasesWithClicks($order);
$wooClickId = $this->getWooClickId($order);
$trackingEnabled = $this->trackingConfig->isEmailTrackingEnabled();
// Neither system attributed the order and the writer cannot run: there is
// no migration signal to record, so avoid stamping every order with meta.
if (!$purchases && $wooClickId === null && !$trackingEnabled) {
return;
}
$record = $this->buildRecord($order, $purchases, $wooClickId, $trackingEnabled, $trigger);
$encoded = $this->wp->wpJsonEncode($record);
if (!is_string($encoded) || $encoded === '') {
// Leave the order without a record so the next status change retries,
// rather than persisting an empty blob that reads as a corrupt record.
return;
}
$order->update_meta_data(self::RECONCILIATION_META_KEY, $encoded);
$order->save_meta_data();
}
/**
* Orders created before the writer first activated have no Woo-stored attribution
* by design (STOMAIL-8135, decision 6) and must not be counted as divergences.
*/
private function isAfterWritesStartedBoundary(WC_Order $order): bool {
$boundary = $this->wp->getOption(OrderAttributionWriter::WRITES_STARTED_AT_OPTION);
$createdAt = $order->get_date_created();
if (!is_string($boundary) || $boundary === '' || is_null($createdAt)) {
return false;
}
try {
$boundaryDate = new \DateTimeImmutable($boundary, new \DateTimeZone('UTC'));
} catch (\Exception $e) {
return false;
}
return $createdAt->getTimestamp() >= $boundaryDate->getTimestamp();
}
/**
* @return StatisticsWooCommercePurchaseEntity[]
*/
private function getPurchasesWithClicks(WC_Order $order): array {
$purchases = $this->purchasesRepository->findBy(['orderId' => $order->get_id()]);
return array_values(array_filter($purchases, function(StatisticsWooCommercePurchaseEntity $purchase): bool {
return !is_null($purchase->getClick());
}));
}
private function getWooClickId(WC_Order $order): ?int {
$value = $order->get_meta(OrderAttributionFields::getMetaKey(OrderAttributionFields::FIELD_CLICK_ID));
if (!is_scalar($value) || (string)$value === '') {
return null;
}
return (int)$value;
}
/**
* @param StatisticsWooCommercePurchaseEntity[] $purchases
* @return array<string, mixed>
*/
private function buildRecord(
WC_Order $order,
array $purchases,
?int $wooClickId,
bool $trackingEnabled,
string $trigger
): array {
$legacyClickIds = $this->getLegacyClickIds($purchases);
$lastClickPurchase = $this->getLastClickPurchase($purchases);
$lastClick = $lastClickPurchase ? $lastClickPurchase->getClick() : null;
$legacyLastClickId = $lastClick ? (int)$lastClick->getId() : null;
$foreignSourcePreserved = $this->isForeignSourcePreserved($order, $wooClickId);
[$outcome, $reason, $classification] = $this->resolveOutcome(
$legacyClickIds,
$legacyLastClickId,
$wooClickId,
$trackingEnabled,
$foreignSourcePreserved
);
$purchaseStates = $this->wooHelper->getPurchaseStates();
$createdAt = $order->get_date_created();
return [
'version' => self::RECORD_VERSION,
'trigger' => $trigger,
'outcome' => $outcome,
'reason' => $reason,
'classification' => $classification,
'legacy_click_ids' => $legacyClickIds,
'legacy_min_click_id' => $legacyClickIds ? min($legacyClickIds) : null,
'legacy_last_click_id' => $legacyLastClickId,
'woo_click_id' => $wooClickId,
'legacy_revenue' => $this->getLegacyDedupedRevenue($purchases, $purchaseStates),
'legacy_revenue_per_newsletter' => $this->getLegacyPerNewsletterRevenue($purchases, $purchaseStates),
'woo_revenue' => $this->getWooRevenue($order, $wooClickId, $purchaseStates),
'currency' => $order->get_currency(),
'order_status' => $order->get_status(),
'foreign_source_preserved' => $foreignSourcePreserved,
'order_created_at' => $createdAt ? gmdate('Y-m-d H:i:s', $createdAt->getTimestamp()) : null,
'reconciled_at' => gmdate('Y-m-d H:i:s'),
];
}
/**
* @param int[] $legacyClickIds
* @return array{0: string, 1: string, 2: string|null}
*/
private function resolveOutcome(
array $legacyClickIds,
?int $legacyLastClickId,
?int $wooClickId,
bool $trackingEnabled,
bool $foreignSourcePreserved
): array {
if (!$legacyClickIds && $wooClickId === null) {
$reason = $trackingEnabled ? self::REASON_NO_CLICK_CANDIDATE : self::REASON_TRACKING_DISABLED;
return [self::OUTCOME_MATCH, $reason, null];
}
if ($legacyClickIds && $wooClickId === null) {
if (!$this->isWooAttributionAvailable()) {
return [self::OUTCOME_DIVERGED, self::REASON_WOO_ATTRIBUTION_DISABLED, self::CLASSIFICATION_UNEXPLAINED];
}
if (!$trackingEnabled) {
return [self::OUTCOME_DIVERGED, self::REASON_TRACKING_DISABLED, self::CLASSIFICATION_UNEXPLAINED];
}
return [self::OUTCOME_DIVERGED, self::REASON_WOO_ATTRIBUTION_MISSING, self::CLASSIFICATION_UNEXPLAINED];
}
if (!$legacyClickIds) {
return [self::OUTCOME_DIVERGED, self::REASON_LEGACY_ATTRIBUTION_MISSING, self::CLASSIFICATION_UNEXPLAINED];
}
if ($wooClickId !== $legacyLastClickId) {
return [self::OUTCOME_DIVERGED, self::REASON_CANONICAL_CLICK_MISMATCH, self::CLASSIFICATION_UNEXPLAINED];
}
if (count($legacyClickIds) > 1) {
return [self::OUTCOME_DIVERGED, self::REASON_LAST_CLICK_COLLAPSE, self::CLASSIFICATION_INTENTIONAL];
}
if ($foreignSourcePreserved) {
return [self::OUTCOME_DIVERGED, self::REASON_FOREIGN_SOURCE_PRESERVED, self::CLASSIFICATION_INTENTIONAL];
}
return [self::OUTCOME_MATCH, self::REASON_MATCHED, null];
}
/**
* @param StatisticsWooCommercePurchaseEntity[] $purchases
* @return int[]
*/
private function getLegacyClickIds(array $purchases): array {
$clickIds = [];
foreach ($purchases as $purchase) {
$click = $purchase->getClick();
if ($click) {
$clickIds[] = (int)$click->getId();
}
}
sort($clickIds);
return $clickIds;
}
/**
* Mirrors OrderAttributionWriter::isMoreRecent so the legacy last-click candidate
* is computed with the same tie-breaking as the writer's canonical click.
*
* @param StatisticsWooCommercePurchaseEntity[] $purchases
*/
private function getLastClickPurchase(array $purchases): ?StatisticsWooCommercePurchaseEntity {
$latest = null;
foreach ($purchases as $purchase) {
if (is_null($latest) || $this->isMoreRecent($purchase, $latest)) {
$latest = $purchase;
}
}
return $latest;
}
private function isMoreRecent(
StatisticsWooCommercePurchaseEntity $purchase,
StatisticsWooCommercePurchaseEntity $other
): bool {
$click = $purchase->getClick();
$otherClick = $other->getClick();
if (is_null($click) || is_null($otherClick)) {
return !is_null($click);
}
$clickUpdatedAt = $click->getUpdatedAt();
$otherUpdatedAt = $otherClick->getUpdatedAt();
if ($clickUpdatedAt->getTimestamp() === $otherUpdatedAt->getTimestamp()) {
return (int)$click->getId() > (int)$otherClick->getId();
}
return $clickUpdatedAt > $otherUpdatedAt;
}
/**
* Today's campaign-revenue dedup counts each order once via its MIN(click_id) row
* (StatisticsWooCommercePurchasesRepository::getRevenuesByCampaigns).
*
* @param StatisticsWooCommercePurchaseEntity[] $purchases
* @param string[] $purchaseStates
*/
private function getLegacyDedupedRevenue(array $purchases, array $purchaseStates): float {
$minClickPurchase = null;
$minClickId = null;
foreach ($purchases as $purchase) {
$click = $purchase->getClick();
if (is_null($click)) {
continue;
}
if (is_null($minClickId) || (int)$click->getId() < $minClickId) {
$minClickId = (int)$click->getId();
$minClickPurchase = $purchase;
}
}
if (is_null($minClickPurchase) || !in_array($minClickPurchase->getStatus(), $purchaseStates, true)) {
return 0.0;
}
return $minClickPurchase->getOrderPriceTotal();
}
/**
* Today's per-newsletter crediting counts the order total once per credited
* newsletter row.
*
* @param StatisticsWooCommercePurchaseEntity[] $purchases
* @param string[] $purchaseStates
*/
private function getLegacyPerNewsletterRevenue(array $purchases, array $purchaseStates): float {
$revenue = 0.0;
foreach ($purchases as $purchase) {
if (in_array($purchase->getStatus(), $purchaseStates, true)) {
$revenue += $purchase->getOrderPriceTotal();
}
}
return $revenue;
}
/**
* The Woo-backed read model (STOMAIL-8138) credits the live refund-aware order
* value once to the canonical click when the order is in a purchase state.
*
* @param string[] $purchaseStates
*/
private function getWooRevenue(WC_Order $order, ?int $wooClickId, array $purchaseStates): float {
if ($wooClickId === null || !in_array($order->get_status(), $purchaseStates, true)) {
return 0.0;
}
return (float)$order->get_remaining_refund_amount();
}
private function isForeignSourcePreserved(WC_Order $order, ?int $wooClickId): bool {
if ($wooClickId === null) {
return false;
}
$sourceType = $order->get_meta(OrderAttributionFields::getMetaKey('source_type'));
$utmSource = $order->get_meta(OrderAttributionFields::getMetaKey('utm_source'));
$sourceType = is_scalar($sourceType) ? (string)$sourceType : '';
$utmSource = is_scalar($utmSource) ? (string)$utmSource : '';
$isOverwritable = in_array($sourceType, OrderAttributionWriter::OVERWRITABLE_SOURCE_TYPES, true)
|| $utmSource === 'mailpoet';
return !$isOverwritable;
}
private function isWooAttributionAvailable(): bool {
return class_exists(FeaturesUtil::class) && FeaturesUtil::feature_is_enabled('order_attribution');
}
}