| 1 |
<?php |
| 2 |
/** |
| 3 |
* Install lifecycle analytics. |
| 4 |
* |
| 5 |
* Reports the four events that make retention measurable — install, upgrade, |
| 6 |
* deactivation, uninstall — and keeps the PostHog `site` group properties |
| 7 |
* current via a daily refresh. |
| 8 |
* |
| 9 |
* The awkward part of this surface is that consent and installation happen in |
| 10 |
* the wrong order. `tracking_consent` starts at `undecided` and the consent |
| 11 |
* pop-up is only shown on the NEXT admin page load, so an install event fired |
| 12 |
* from the activation hook is guaranteed to be suppressed by the consent gate |
| 13 |
* and lost. Install and upgrade events are therefore RECORDED at the moment |
| 14 |
* they happen and REPORTED once consent allows it, carrying their original |
| 15 |
* timestamp so retention cohorts stay accurate. |
| 16 |
* |
| 17 |
* Consent is honoured at both ends: nothing is queued once the user has said |
| 18 |
* no, and a queued event is discarded rather than sent if the answer turns out |
| 19 |
* to be no. |
| 20 |
* |
| 21 |
* @package WCPOS\WooCommercePOS\Services |
| 22 |
*/ |
| 23 |
|
| 24 |
namespace WCPOS\WooCommercePOS\Services; |
| 25 |
|
| 26 |
/** |
| 27 |
* Lifecycle_Events service class. |
| 28 |
*/ |
| 29 |
class Lifecycle_Events { |
| 30 |
/** |
| 31 |
* Option holding events recorded before consent was decided. |
| 32 |
* |
| 33 |
* @var string |
| 34 |
*/ |
| 35 |
const PENDING_OPTION = 'woocommerce_pos_analytics_pending_events'; |
| 36 |
|
| 37 |
/** |
| 38 |
* Option latching that the install event has been recorded. |
| 39 |
* |
| 40 |
* Set at activation and never cleared while the plugin is installed, so a |
| 41 |
* deactivate/reactivate cycle does not report a second install. |
| 42 |
* |
| 43 |
* @var string |
| 44 |
*/ |
| 45 |
const INSTALL_RECORDED_OPTION = 'woocommerce_pos_analytics_install_recorded'; |
| 46 |
|
| 47 |
/** |
| 48 |
* Cron hook for the daily group property refresh. |
| 49 |
* |
| 50 |
* @var string |
| 51 |
*/ |
| 52 |
const REFRESH_HOOK = 'wcpos_analytics_group_refresh'; |
| 53 |
|
| 54 |
/** |
| 55 |
* Option holding the most recently reported order-count band. |
| 56 |
* |
| 57 |
* The uninstaller runs with no plugin code loaded and no warm cache to rely |
| 58 |
* on. Persisting the band each refresh means the churn metric survives an |
| 59 |
* uninstall that happens hours after the last page load. |
| 60 |
* |
| 61 |
* @var string |
| 62 |
*/ |
| 63 |
const LAST_ORDER_BAND_OPTION = 'woocommerce_pos_analytics_order_band'; |
| 64 |
|
| 65 |
/** |
| 66 |
* Option latching that the site's first POS app open has been recorded. |
| 67 |
* |
| 68 |
* Written regardless of consent: it is a local flag and nothing leaves the |
| 69 |
* site. Latching only for consenting sites would mean a store that used the |
| 70 |
* POS for a month before saying yes has its next open reported as its first. |
| 71 |
* |
| 72 |
* @var string |
| 73 |
*/ |
| 74 |
const FIRST_OPEN_OPTION = 'woocommerce_pos_first_open_recorded'; |
| 75 |
|
| 76 |
/** |
| 77 |
* The value the first-open latch stores. |
| 78 |
* |
| 79 |
* DO NOT make this a timestamp. `add_option()` is not the atomic claim it |
| 80 |
* looks like: it does a get_option() check and then an |
| 81 |
* `INSERT ... ON DUPLICATE KEY UPDATE`. Two racing callers can both pass the |
| 82 |
* check, and if their values DIFFER the loser's upsert changes the row, so |
| 83 |
* MySQL reports affected rows and add_option() returns true for both — two |
| 84 |
* "firsts" for one event. With an identical value the loser's upsert changes |
| 85 |
* nothing, affects 0 rows, and add_option() correctly returns false. |
| 86 |
* |
| 87 |
* The constant is what makes the latch safe. The timestamp people will want |
| 88 |
* to store here is already on the event itself. |
| 89 |
* |
| 90 |
* @var string |
| 91 |
*/ |
| 92 |
const LATCH_VALUE = '1'; |
| 93 |
|
| 94 |
/** |
| 95 |
* Maximum queued events. |
| 96 |
* |
| 97 |
* The queue only ever holds one install plus a handful of upgrades, so this |
| 98 |
* is a backstop against an unbounded option, not a working limit. |
| 99 |
* |
| 100 |
* @var int |
| 101 |
*/ |
| 102 |
const MAX_PENDING = 20; |
| 103 |
|
| 104 |
/** |
| 105 |
* Register the hooks this service owns. |
| 106 |
*/ |
| 107 |
public function register_hooks(): void { |
| 108 |
add_action( 'admin_init', array( $this, 'flush_pending' ) ); |
| 109 |
add_action( 'admin_init', array( $this, 'maybe_schedule_refresh' ) ); |
| 110 |
add_action( self::REFRESH_HOOK, array( $this, 'refresh_group_properties' ) ); |
| 111 |
} |
| 112 |
|
| 113 |
/** |
| 114 |
* Record the install event. Called from the activation hook. |
| 115 |
* |
| 116 |
* No-op after the first call, so only a genuine first install reports. |
| 117 |
*/ |
| 118 |
public function record_install(): void { |
| 119 |
if ( get_option( self::INSTALL_RECORDED_OPTION ) ) { |
| 120 |
return; |
| 121 |
} |
| 122 |
|
| 123 |
// Latch before recording: if the record fails we would rather lose one |
| 124 |
// install event than report an install on every reactivation. |
| 125 |
update_option( self::INSTALL_RECORDED_OPTION, 1, false ); |
| 126 |
|
| 127 |
// Sites that predate this code are not new installs — reporting one on |
| 128 |
// the upgrade that introduces this latch would invent an install spike |
| 129 |
// out of the existing user base. Two independent signals say "WCPOS has |
| 130 |
// run here before": a stored db version (written by the first upgrade |
| 131 |
// pass) and an install timestamp (written the first time the landing |
| 132 |
// profile is built). Latch silently on either. |
| 133 |
if ( '0' !== (string) Settings::get_db_version() ) { |
| 134 |
return; |
| 135 |
} |
| 136 |
|
| 137 |
if ( false !== get_option( 'woocommerce_pos_installed_at' ) ) { |
| 138 |
return; |
| 139 |
} |
| 140 |
|
| 141 |
// Stamp the install epoch NOW. Landing_Profile would otherwise create it |
| 142 |
// the first time the profile is built — which, for a site that leaves |
| 143 |
// consent undecided for a month, is a month late. Every days_since_install |
| 144 |
// the site ever reports would be short by that gap. |
| 145 |
add_option( 'woocommerce_pos_installed_at', time() ); |
| 146 |
|
| 147 |
$this->record( 'wcpos_installed' ); |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* Record a version upgrade. |
| 152 |
* |
| 153 |
* @param string $from_version Version being upgraded from. |
| 154 |
* @param string $to_version Version being upgraded to. |
| 155 |
*/ |
| 156 |
public function record_upgrade( string $from_version, string $to_version ): void { |
| 157 |
// A fresh install runs the upgrade path with no previous version. That |
| 158 |
// is an install, and it has already been reported as one. |
| 159 |
if ( '' === $from_version || '0' === $from_version ) { |
| 160 |
return; |
| 161 |
} |
| 162 |
|
| 163 |
$this->record( |
| 164 |
'wcpos_upgraded', |
| 165 |
array( |
| 166 |
'from_version' => $from_version, |
| 167 |
'to_version' => $to_version, |
| 168 |
) |
| 169 |
); |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* Report that the POS app was opened. |
| 174 |
* |
| 175 |
* The activation step no admin-side signal can see. Recorded from the POS |
| 176 |
* template render rather than by tracking the menu link, so a bookmark, a |
| 177 |
* direct URL or a till that never visits wp-admin all count — and so it |
| 178 |
* counts opens rather than clicks that may never arrive. |
| 179 |
* |
| 180 |
* De-duplicated per user per day. A till is reloaded constantly; without a |
| 181 |
* window this would repeat the mistake that made `upgrade_cta_viewed` 90% of |
| 182 |
* the dataset. A day is also the useful unit: it makes this a daily-active |
| 183 |
* signal rather than a page-load counter. |
| 184 |
* |
| 185 |
* The site's first open is flagged rather than given its own event name, so |
| 186 |
* activation and engagement come off one series. |
| 187 |
*/ |
| 188 |
public function report_app_opened(): void { |
| 189 |
// Latch the first open BEFORE the consent check, and never transmit it |
| 190 |
// from here — it is a local option, nothing leaves the site. Latching |
| 191 |
// only for consenting sites would mean a store that used the POS for a |
| 192 |
// month and then said yes would have its next open reported as its |
| 193 |
// first, which is untrue. This way an unknown first open stays unknown |
| 194 |
// rather than becoming a wrong one. |
| 195 |
// |
| 196 |
// See LATCH_VALUE: the constant is what makes this a safe claim. |
| 197 |
// Autoloaded because it is read on every POS open, and it is one byte. |
| 198 |
$is_first_open = add_option( self::FIRST_OPEN_OPTION, self::LATCH_VALUE, '', true ); |
| 199 |
|
| 200 |
$analytics = Analytics::instance(); |
| 201 |
|
| 202 |
if ( ! $analytics->is_enabled() ) { |
| 203 |
return; |
| 204 |
} |
| 205 |
|
| 206 |
$analytics->capture_once( |
| 207 |
'pos_app_opened', |
| 208 |
array( 'is_first_open' => $is_first_open ), |
| 209 |
'pos_app_opened' |
| 210 |
); |
| 211 |
|
| 212 |
// A POS-only store may never load a wp-admin page, and admin_init is |
| 213 |
// where the queue is normally drained. Without this, events recorded |
| 214 |
// before consent — the install, the first sale — would sit unsent |
| 215 |
// forever on exactly the stores that use the product most. |
| 216 |
$this->flush_pending(); |
| 217 |
} |
| 218 |
|
| 219 |
/** |
| 220 |
* Record that the consent prompt was shown. |
| 221 |
* |
| 222 |
* This is the one event whose subject has not consented yet — by |
| 223 |
* definition, since the prompt only renders while the answer is undecided. |
| 224 |
* So it is queued, never sent, and reaches PostHog only if the user goes on |
| 225 |
* to say yes. Somebody who declines or ignores the prompt transmits |
| 226 |
* nothing, which is the only honest reading of what they were asked. |
| 227 |
* |
| 228 |
* The consequence, stated plainly because it limits what the data can |
| 229 |
* answer: we see views only for people who accepted, so a true acceptance |
| 230 |
* RATE is not computable from plugin telemetry and never will be. What this |
| 231 |
* does answer is which surface converted and how long the decision took. |
| 232 |
* For the rate, compare consenting sites against the public wordpress.org |
| 233 |
* active-install count — no extra collection required. |
| 234 |
* |
| 235 |
* @param string $surface Where the prompt was shown: `modal` or `callout`. |
| 236 |
*/ |
| 237 |
public function record_consent_prompt_viewed( string $surface ): void { |
| 238 |
// The prompt re-renders on every allowed admin screen until the user |
| 239 |
// answers, so record the first sighting only. |
| 240 |
foreach ( (array) get_option( self::PENDING_OPTION, array() ) as $entry ) { |
| 241 |
if ( \is_array( $entry ) && 'consent_notice_viewed' === ( $entry['event'] ?? '' ) ) { |
| 242 |
return; |
| 243 |
} |
| 244 |
} |
| 245 |
|
| 246 |
$this->record( 'consent_notice_viewed', array( 'surface' => $surface ) ); |
| 247 |
} |
| 248 |
|
| 249 |
/** |
| 250 |
* Report that consent was granted. |
| 251 |
* |
| 252 |
* Sent immediately: the user has just said yes, so the gate is open. Also |
| 253 |
* flushes anything queued while they were deciding, rather than leaving it |
| 254 |
* for the next admin page load. |
| 255 |
* |
| 256 |
* There is deliberately no counterpart for "declined" or "dismissed". |
| 257 |
* Reporting that someone refused telemetry, by sending telemetry, is the |
| 258 |
* one thing this surface must never do. |
| 259 |
* |
| 260 |
* No surface is recorded: the server cannot tell which prompt the user |
| 261 |
* answered in — both can be on screen — and the paired |
| 262 |
* `consent_notice_viewed` already carries the surface that was shown. |
| 263 |
*/ |
| 264 |
public function report_consent_granted(): void { |
| 265 |
$analytics = Analytics::instance(); |
| 266 |
|
| 267 |
// The choice was written in this request; the cached answer predates it. |
| 268 |
$analytics->clear_consent_cache(); |
| 269 |
|
| 270 |
if ( ! $analytics->is_enabled() ) { |
| 271 |
return; |
| 272 |
} |
| 273 |
|
| 274 |
$installed_at = (int) get_option( 'woocommerce_pos_installed_at', 0 ); |
| 275 |
$properties = array(); |
| 276 |
|
| 277 |
if ( $installed_at > 0 ) { |
| 278 |
$properties['days_since_install'] = max( 0, (int) floor( ( time() - $installed_at ) / DAY_IN_SECONDS ) ); |
| 279 |
} |
| 280 |
|
| 281 |
$analytics->capture( 'consent_notice_accepted', $properties ); |
| 282 |
|
| 283 |
// Send what was held back while the answer was pending. |
| 284 |
$this->flush_pending(); |
| 285 |
} |
| 286 |
|
| 287 |
/** |
| 288 |
* Report the deactivation event. Called from the deactivation hook. |
| 289 |
* |
| 290 |
* Reported immediately rather than queued: a deactivated plugin never gets |
| 291 |
* another admin page load to flush from, so an unsent deactivation would |
| 292 |
* sit in the queue until the user reactivates, by which point it is a lie. |
| 293 |
*/ |
| 294 |
public function report_deactivation(): void { |
| 295 |
// A network-wide deactivation walks every blog in one request, and |
| 296 |
// Analytics caches the consent answer for the request. Without this the |
| 297 |
// first blog's "yes" would be reused for blogs that said no. |
| 298 |
Analytics::instance()->clear_consent_cache(); |
| 299 |
|
| 300 |
// Check consent before gathering anything: the churn properties run |
| 301 |
// store queries, and a site that opted out should not pay for them. |
| 302 |
if ( ! Analytics::instance()->is_enabled() ) { |
| 303 |
return; |
| 304 |
} |
| 305 |
|
| 306 |
$analytics = Analytics::instance(); |
| 307 |
|
| 308 |
// `wp plugin deactivate` runs with no current user, so get_distinct_id() |
| 309 |
// comes back empty and capture() would drop the event. Fall back to the |
| 310 |
// site identity, the same way the group refresh and the uninstall |
| 311 |
// reporter do. |
| 312 |
$distinct_id = $analytics->get_distinct_id(); |
| 313 |
if ( '' === $distinct_id ) { |
| 314 |
$site_id = $analytics->get_site_id(); |
| 315 |
if ( '' === $site_id ) { |
| 316 |
return; |
| 317 |
} |
| 318 |
|
| 319 |
$distinct_id = 'site_' . $site_id; |
| 320 |
} |
| 321 |
|
| 322 |
$analytics->capture( 'wcpos_deactivated', $this->get_churn_properties(), $distinct_id ); |
| 323 |
} |
| 324 |
|
| 325 |
/** |
| 326 |
* Throw away anything queued while the answer was pending. |
| 327 |
* |
| 328 |
* Called when the user declines, so the refusal takes effect in the request |
| 329 |
* that records it rather than whenever an admin page next happens to load. |
| 330 |
*/ |
| 331 |
public function discard_pending(): void { |
| 332 |
delete_option( self::PENDING_OPTION ); |
| 333 |
} |
| 334 |
|
| 335 |
/** |
| 336 |
* Send any events recorded before consent was decided. |
| 337 |
* |
| 338 |
* Gated on consent first so that a site which has not opted in never pays |
| 339 |
* for the queue lookup. |
| 340 |
*/ |
| 341 |
public function flush_pending(): void { |
| 342 |
if ( ! Analytics::instance()->is_enabled() ) { |
| 343 |
// A queued event outlives an undecided answer. Once the answer is |
| 344 |
// no, drop it rather than leaving it to sit in the options table |
| 345 |
// waiting for a consent that is not coming. |
| 346 |
// The persisted answer: a settings filter must not be able to keep a |
| 347 |
// declined merchant's queued events sitting in the options table. |
| 348 |
if ( 'denied' === Settings::instance()->raw_tracking_consent() ) { |
| 349 |
delete_option( self::PENDING_OPTION ); |
| 350 |
} |
| 351 |
|
| 352 |
return; |
| 353 |
} |
| 354 |
|
| 355 |
$pending = get_option( self::PENDING_OPTION ); |
| 356 |
if ( empty( $pending ) || ! \is_array( $pending ) ) { |
| 357 |
return; |
| 358 |
} |
| 359 |
|
| 360 |
// Clear first. A failed send is not worth retrying forever, and leaving |
| 361 |
// the queue populated would re-send on every admin page load. |
| 362 |
delete_option( self::PENDING_OPTION ); |
| 363 |
|
| 364 |
$analytics = Analytics::instance(); |
| 365 |
|
| 366 |
foreach ( $pending as $entry ) { |
| 367 |
if ( ! \is_array( $entry ) || empty( $entry['event'] ) ) { |
| 368 |
continue; |
| 369 |
} |
| 370 |
|
| 371 |
$analytics->capture( |
| 372 |
(string) $entry['event'], |
| 373 |
\is_array( $entry['properties'] ?? null ) ? $entry['properties'] : array(), |
| 374 |
'', |
| 375 |
isset( $entry['timestamp'] ) ? (string) $entry['timestamp'] : '' |
| 376 |
); |
| 377 |
} |
| 378 |
|
| 379 |
// Queued events describe the install, so the site profile that goes with |
| 380 |
// them is worth sending in the same pass. |
| 381 |
$this->refresh_group_properties(); |
| 382 |
} |
| 383 |
|
| 384 |
/** |
| 385 |
* Schedule the daily group property refresh if consent allows it. |
| 386 |
*/ |
| 387 |
public function maybe_schedule_refresh(): void { |
| 388 |
if ( ! Analytics::instance()->is_enabled() ) { |
| 389 |
// Consent can be withdrawn — stop refreshing if it has been. Guarded |
| 390 |
// so an opted-out site does not touch the cron array on every load. |
| 391 |
if ( wp_next_scheduled( self::REFRESH_HOOK ) ) { |
| 392 |
$this->clear_schedule(); |
| 393 |
} |
| 394 |
|
| 395 |
return; |
| 396 |
} |
| 397 |
|
| 398 |
if ( ! wp_next_scheduled( self::REFRESH_HOOK ) ) { |
| 399 |
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::REFRESH_HOOK ); |
| 400 |
} |
| 401 |
} |
| 402 |
|
| 403 |
/** |
| 404 |
* Clear the scheduled refresh. |
| 405 |
*/ |
| 406 |
public function clear_schedule(): void { |
| 407 |
wp_clear_scheduled_hook( self::REFRESH_HOOK ); |
| 408 |
} |
| 409 |
|
| 410 |
/** |
| 411 |
* Transient guarding the on-page-load group refresh. |
| 412 |
* |
| 413 |
* @var string |
| 414 |
*/ |
| 415 |
const REFRESH_THROTTLE_TRANSIENT = 'wcpos_analytics_group_refreshed'; |
| 416 |
|
| 417 |
/** |
| 418 |
* Refresh the site profile from a page load, at most once a day. |
| 419 |
* |
| 420 |
* The scheduled refresh is the primary path; this is the fallback for |
| 421 |
* installs where WP-Cron is unreliable or disabled. Throttled because the |
| 422 |
* profile is a slow-moving description of the site, not a page-view metric. |
| 423 |
*/ |
| 424 |
public function maybe_refresh_group_properties(): void { |
| 425 |
if ( ! Analytics::instance()->is_enabled() ) { |
| 426 |
return; |
| 427 |
} |
| 428 |
|
| 429 |
if ( false !== get_transient( self::REFRESH_THROTTLE_TRANSIENT ) ) { |
| 430 |
return; |
| 431 |
} |
| 432 |
|
| 433 |
set_transient( self::REFRESH_THROTTLE_TRANSIENT, 1, DAY_IN_SECONDS ); |
| 434 |
|
| 435 |
$this->refresh_group_properties(); |
| 436 |
} |
| 437 |
|
| 438 |
/** |
| 439 |
* Push the current site profile onto the PostHog `site` group. |
| 440 |
*/ |
| 441 |
public function refresh_group_properties(): void { |
| 442 |
$analytics = Analytics::instance(); |
| 443 |
$site_id = $analytics->get_site_id(); |
| 444 |
|
| 445 |
if ( '' === $site_id ) { |
| 446 |
return; |
| 447 |
} |
| 448 |
|
| 449 |
$properties = ( new Analytics_Profile() )->get_group_properties(); |
| 450 |
|
| 451 |
// Leave the band somewhere uninstall.php can read it without the plugin. |
| 452 |
if ( isset( $properties['order_count_band'] ) ) { |
| 453 |
update_option( self::LAST_ORDER_BAND_OPTION, $properties['order_count_band'], false ); |
| 454 |
} |
| 455 |
|
| 456 |
$analytics->group( 'site', $site_id, $properties ); |
| 457 |
} |
| 458 |
|
| 459 |
/** |
| 460 |
* Properties describing how much the site had invested when it churned. |
| 461 |
* |
| 462 |
* The order count is banded like every other count we report. Churn |
| 463 |
* analysis only asks whether they left with nothing or left with a real |
| 464 |
* trading history, and a band answers that without carrying an exact |
| 465 |
* figure out of the store. |
| 466 |
* |
| 467 |
* @return array<string, mixed> |
| 468 |
*/ |
| 469 |
private function get_churn_properties(): array { |
| 470 |
$metrics = ( new Landing_Profile() )->get_metrics(); |
| 471 |
|
| 472 |
return array( |
| 473 |
'days_since_install' => (int) ( $metrics['days_since_install'] ?? 0 ), |
| 474 |
'order_count_band' => Analytics_Profile::band( (int) ( $metrics['order_count'] ?? 0 ) ), |
| 475 |
); |
| 476 |
} |
| 477 |
|
| 478 |
/** |
| 479 |
* Queue a lifecycle event for the next admin page load. |
| 480 |
* |
| 481 |
* Always queued, never sent inline — and that is not just about consent. |
| 482 |
* Install and upgrade both run before the plugin is fully booted: the |
| 483 |
* activation hook fires in a request where `plugins_loaded` has already |
| 484 |
* passed, so Init never ran and `wcpos-functions.php` is not loaded, and |
| 485 |
* the upgrade check runs before `new Init()`. Capturing from either point |
| 486 |
* would call `wcpos_get_site_uuid()` before it exists and fatal the |
| 487 |
* activation. Queueing needs nothing but the options API. |
| 488 |
* |
| 489 |
* The event carries only its own properties. The environment and store |
| 490 |
* snapshot lives on the `site` group, which flush_pending() refreshes in |
| 491 |
* the same pass — no need to copy it onto every event. |
| 492 |
* |
| 493 |
* @param string $event Event name. |
| 494 |
* @param array $properties Event properties. |
| 495 |
*/ |
| 496 |
private function record( string $event, array $properties = array() ): void { |
| 497 |
// An explicit "no" is an answer, not a delay. Read the persisted value: |
| 498 |
// nothing should be queued about a merchant who declined, even if a |
| 499 |
// settings filter presents a different answer. |
| 500 |
if ( 'denied' === Settings::instance()->raw_tracking_consent() ) { |
| 501 |
return; |
| 502 |
} |
| 503 |
|
| 504 |
$pending = get_option( self::PENDING_OPTION ); |
| 505 |
if ( ! \is_array( $pending ) ) { |
| 506 |
$pending = array(); |
| 507 |
} |
| 508 |
|
| 509 |
if ( \count( $pending ) >= self::MAX_PENDING ) { |
| 510 |
return; |
| 511 |
} |
| 512 |
|
| 513 |
$pending[] = array( |
| 514 |
'event' => $event, |
| 515 |
'properties' => $properties, |
| 516 |
'timestamp' => gmdate( 'c' ), |
| 517 |
); |
| 518 |
|
| 519 |
update_option( self::PENDING_OPTION, $pending, false ); |
| 520 |
} |
| 521 |
} |
| 522 |
|