PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.2
MailPoet – Newsletters, Email Marketing, and Automation v5.34.2
5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.0 5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / lib / WooCommerce / OrderAttributionWriter.php
mailpoet / lib / WooCommerce Last commit date
Emails 2 months ago Integrations 2 months ago MultichannelMarketing 2 years ago TransactionalEmails 10 months ago WooCommerceBookings 1 month ago WooCommerceSubscriptions 2 months ago CouponPreProcessor.php 2 months ago Emails.php 8 months ago Helper.php 1 month ago MailPoetTask.php 2 years ago NonPersistablePreviewData.php 1 month ago OrderAttributionFields.php 1 month ago OrderAttributionRevenueReader.php 2 weeks ago OrderAttributionWriter.php 1 month ago RandomCouponCodeGenerator.php 2 months ago Settings.php 1 year ago SubscriberEngagement.php 3 years ago Subscription.php 2 months ago Tracker.php 2 years ago TransactionalEmailHooks.php 1 year ago TransactionalEmails.php 1 year ago WooSystemInfo.php 1 month ago WooSystemInfoController.php 1 year ago index.php 3 years ago
OrderAttributionWriter.php
280 lines
1 <?php declare(strict_types = 1);
2
3 namespace MailPoet\WooCommerce;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use Automattic\WooCommerce\Utilities\FeaturesUtil;
9 use MailPoet\Entities\StatisticsClickEntity;
10 use MailPoet\Entities\SubscriberEntity;
11 use MailPoet\Settings\TrackingConfig;
12 use MailPoet\Statistics\StatisticsClicksRepository;
13 use MailPoet\Statistics\Track\Clicks;
14 use MailPoet\Statistics\Track\WooCommercePurchases;
15 use MailPoet\Subscribers\SubscribersRepository;
16 use MailPoet\Util\Cookies;
17 use MailPoet\WP\Functions as WPFunctions;
18 use WC_Order;
19
20 class OrderAttributionWriter {
21 const WRITES_STARTED_AT_OPTION = 'mailpoet_woo_attribution_writes_started_at';
22
23 // 'typein' is Woo's source type for direct traffic. A clear non-MailPoet source
24 // (organic, referral, non-MailPoet utm, admin, mobile_app) is never overwritten.
25 const OVERWRITABLE_SOURCE_TYPES = ['', 'typein', 'unknown'];
26
27 /** @var WPFunctions */
28 private $wp;
29
30 /** @var Helper */
31 private $wooHelper;
32
33 /** @var TrackingConfig */
34 private $trackingConfig;
35
36 /** @var StatisticsClicksRepository */
37 private $statisticsClicksRepository;
38
39 /** @var SubscribersRepository */
40 private $subscribersRepository;
41
42 /** @var Cookies */
43 private $cookies;
44
45 public function __construct(
46 WPFunctions $wp,
47 Helper $wooHelper,
48 TrackingConfig $trackingConfig,
49 StatisticsClicksRepository $statisticsClicksRepository,
50 SubscribersRepository $subscribersRepository,
51 Cookies $cookies
52 ) {
53 $this->wp = $wp;
54 $this->wooHelper = $wooHelper;
55 $this->trackingConfig = $trackingConfig;
56 $this->statisticsClicksRepository = $statisticsClicksRepository;
57 $this->subscribersRepository = $subscribersRepository;
58 $this->cookies = $cookies;
59 }
60
61 /**
62 * @param int|WC_Order $order
63 */
64 public function writeForOrder($order): void {
65 if (!$this->isWritePathActive()) {
66 return;
67 }
68 if (!$order instanceof WC_Order) {
69 $order = $this->wooHelper->wcGetOrder($order);
70 }
71 if (!$order instanceof WC_Order) {
72 return;
73 }
74 $this->markWritesStarted();
75
76 $click = $this->resolveCanonicalClick($order);
77 if (!$click) {
78 return;
79 }
80 $this->writeStandardSourceFields($order, $click);
81 $order->save_meta_data();
82 }
83
84 /**
85 * woocommerce_new_order also fires during storefront checkout, before WooCommerce
86 * captures its attribution data. Writing MailPoet meta at that point would make
87 * Woo's has_attribution() check skip its own capture, so this path handles only
88 * admin and (non-Store-API) REST requests; checkout orders are covered by the
89 * woocommerce_order_save_attribution_data and order-status-changed paths.
90 *
91 * @param int|WC_Order $order
92 */
93 public function writeForNewOrder($order): void {
94 if (!$this->isAdminOrRestApiRequest()) {
95 return;
96 }
97 $this->writeForOrder($order);
98 }
99
100 private function isWritePathActive(): bool {
101 return $this->wooHelper->isWooCommerceActive()
102 && $this->isWooAttributionAvailable()
103 && $this->trackingConfig->isEmailTrackingEnabled();
104 }
105
106 private function isWooAttributionAvailable(): bool {
107 return class_exists(FeaturesUtil::class) && FeaturesUtil::feature_is_enabled('order_attribution');
108 }
109
110 private function isAdminOrRestApiRequest(): bool {
111 if ($this->wp->isAdmin()) {
112 return true;
113 }
114 return $this->wooHelper->isWooCommerceRestApiRequest() && !$this->wooHelper->isWooCommerceStoreApiRequest();
115 }
116
117 /**
118 * Persists the historical read boundary defined by the migration contract
119 * (STOMAIL-8135): set once when the write path first activates and never
120 * moved. Runs on init so the boundary predates every post-activation order;
121 * if it were first persisted inside writeForOrder, the triggering order's
122 * date_created would fall before the boundary and the reconciler would skip
123 * the first post-activation orders.
124 */
125 public function markWritesStartedIfActive(): void {
126 if (!$this->isWritePathActive()) {
127 return;
128 }
129 $this->markWritesStarted();
130 }
131
132 // Backstop for the order-save paths; normally already persisted on init.
133 private function markWritesStarted(): void {
134 if ($this->wp->getOption(self::WRITES_STARTED_AT_OPTION)) {
135 return;
136 }
137 $this->wp->addOption(self::WRITES_STARTED_AT_OPTION, gmdate('Y-m-d H:i:s'));
138 }
139
140 /**
141 * Last click wins (STOMAIL-8135): the most recent eligible click before order
142 * creation, across billing-email-matched and cookie-matched candidates. The
143 * candidate set mirrors WooCommercePurchases::trackPurchase; the legacy engine
144 * is intentionally left untouched while both run in parallel for reconciliation.
145 */
146 private function resolveCanonicalClick(WC_Order $order): ?StatisticsClickEntity {
147 $to = $order->get_date_created();
148 if (is_null($to)) {
149 return null;
150 }
151 $from = clone $to;
152 $from->modify(-WooCommercePurchases::USE_CLICKS_SINCE_DAYS_AGO . ' days');
153
154 $candidates = $this->getClicks($order->get_billing_email(), $from, $to);
155 if ($this->trackingConfig->isCookieTrackingEnabled()) {
156 $cookieEmail = $this->getSubscriberEmailFromCookie();
157 if ($cookieEmail && $cookieEmail !== $order->get_billing_email()) {
158 $candidates = array_merge($candidates, $this->getClicks($cookieEmail, $from, $to));
159 }
160 }
161
162 if (!$candidates) {
163 return null;
164 }
165 $latest = array_shift($candidates);
166 foreach ($candidates as $click) {
167 if ($this->isMoreRecent($click, $latest)) {
168 $latest = $click;
169 }
170 }
171 return $latest;
172 }
173
174 private function isMoreRecent(StatisticsClickEntity $click, StatisticsClickEntity $other): bool {
175 $clickUpdatedAt = $click->getUpdatedAt();
176 $otherUpdatedAt = $other->getUpdatedAt();
177 if ($clickUpdatedAt->getTimestamp() === $otherUpdatedAt->getTimestamp()) {
178 return (int)$click->getId() > (int)$other->getId();
179 }
180 return $clickUpdatedAt > $otherUpdatedAt;
181 }
182
183 /**
184 * @return StatisticsClickEntity[]
185 */
186 private function getClicks(?string $email, \DateTimeInterface $from, \DateTimeInterface $to): array {
187 if (!$email) {
188 return [];
189 }
190 $subscriber = $this->subscribersRepository->findOneBy(['email' => $email]);
191 if (!$subscriber instanceof SubscriberEntity) {
192 return [];
193 }
194 return $this->statisticsClicksRepository->findLatestPerNewsletterBySubscriber($subscriber, $from, $to);
195 }
196
197 private function getSubscriberEmailFromCookie(): ?string {
198 $cookieData = $this->cookies->get(Clicks::REVENUE_TRACKING_COOKIE_NAME);
199 if (!$cookieData || !isset($cookieData['statistics_clicks'])) {
200 return null;
201 }
202 try {
203 $click = $this->statisticsClicksRepository->findOneById($cookieData['statistics_clicks']);
204 } catch (\Exception $e) {
205 return null;
206 }
207 if (!$click instanceof StatisticsClickEntity) {
208 return null;
209 }
210 $subscriber = $click->getSubscriber();
211 return $subscriber instanceof SubscriberEntity ? $subscriber->getEmail() : null;
212 }
213
214 private function writeStandardSourceFields(WC_Order $order, StatisticsClickEntity $click): void {
215 $sourceType = $this->getMetaString($order, OrderAttributionFields::getMetaKey('source_type'));
216 $utmSource = $this->getMetaString($order, OrderAttributionFields::getMetaKey('utm_source'));
217 $sessionStartTime = $this->getMetaString($order, OrderAttributionFields::getMetaKey('session_start_time'));
218 if (!self::shouldWriteStandardSourceFields($sourceType, $utmSource, $sessionStartTime, $click->getUpdatedAt(), $this->wp->wpTimezone())) {
219 return;
220 }
221 $values = [
222 'source_type' => 'utm',
223 'utm_source' => 'mailpoet',
224 'utm_medium' => 'email',
225 'utm_source_platform' => 'mailpoet',
226 ];
227 $newsletter = $click->getNewsletter();
228 if ($newsletter) {
229 $subject = (string)$newsletter->getSubject();
230 $values['utm_campaign'] = $subject !== '' ? $subject : 'newsletter-' . $newsletter->getId();
231 }
232 foreach ($values as $fieldName => $value) {
233 $order->update_meta_data(OrderAttributionFields::getMetaKey($fieldName), $this->wp->sanitizeTextField($value));
234 }
235 }
236
237 public static function shouldWriteStandardSourceFields(
238 string $sourceType,
239 string $utmSource,
240 string $sessionStartTime,
241 \DateTimeInterface $clickUpdatedAt,
242 \DateTimeZone $wooSessionTimeZone
243 ): bool {
244 if (in_array($sourceType, self::OVERWRITABLE_SOURCE_TYPES, true) || $utmSource === 'mailpoet') {
245 return true;
246 }
247 $wooSessionStart = self::parseWooSessionStartTime($sessionStartTime, $wooSessionTimeZone);
248 return $wooSessionStart && $clickUpdatedAt->getTimestamp() >= $wooSessionStart->getTimestamp();
249 }
250
251 /**
252 * Woo stores session_start_time as sourcebuster's `current_add.fd`, a wall-clock
253 * string with no timezone. It is the visitor's browser-local time, which the server
254 * cannot recover exactly, so we interpret it in the site timezone as the best
255 * single-region approximation. Multi-region skew is accepted within the documented
256 * last-click tolerance (STOMAIL-8186).
257 */
258 private static function parseWooSessionStartTime(string $sessionStartTime, \DateTimeZone $timeZone): ?\DateTimeImmutable {
259 $sessionStartTime = trim($sessionStartTime);
260 if ($sessionStartTime === '') {
261 return null;
262 }
263 $date = \DateTimeImmutable::createFromFormat(
264 '!Y-m-d H:i:s',
265 $sessionStartTime,
266 $timeZone
267 );
268 $errors = \DateTimeImmutable::getLastErrors();
269 if (!$date || ($errors !== false && ((int)$errors['warning_count'] > 0 || (int)$errors['error_count'] > 0))) {
270 return null;
271 }
272 return $date;
273 }
274
275 private function getMetaString(WC_Order $order, string $metaKey): string {
276 $value = $order->get_meta($metaKey);
277 return is_scalar($value) ? (string)$value : '';
278 }
279 }
280