Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
54.05% |
40 / 74 |
|
63.64% |
7 / 11 |
CRAP | |
0.00% |
0 / 1 |
| WPCOM_Site_Purchase | |
54.05% |
40 / 74 |
|
63.64% |
7 / 11 |
85.62 | |
0.00% |
0 / 1 |
| from_store_row | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| from_synced_payload | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
| flag | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| text | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| might_still_auto_renew | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| first_auto_renew_attempt_date | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| billing_state | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| billing_states_for_blog | |
21.88% |
7 / 32 |
|
0.00% |
0 / 1 |
30.37 | |||
| billing_states_cache_key | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| forget_billing_states | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| bump_billing_stat | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * THIS FILE EXISTS VERBATIM IN WPCOM AND WPCOMSH. |
| 4 | * |
| 5 | * DANGER DANGER DANGER!!! |
| 6 | * If you make any changes to this file you must MANUALLY update this file in both WPCOM and WPCOMSH. |
| 7 | * |
| 8 | * @package WPCOM_Features |
| 9 | */ |
| 10 | |
| 11 | /** |
| 12 | * A site purchase, in the one shape both Simple and Atomic sites serve. |
| 13 | * |
| 14 | * The two platforms answer from different sources — a cached store query on Simple, a payload |
| 15 | * synced from billing on Atomic — and used to hand back bare rows whose fields differed between |
| 16 | * them. This declares the shape instead, so that consumers can be written once. |
| 17 | * |
| 18 | * This is the site-side sibling of `Billing_Upgrade`, which returns a much richer object from |
| 19 | * the purchases endpoints. `Billing_Upgrade` is billing's own record of a purchase and needs |
| 20 | * the billing codebase to build it; `WPCOM_Site_Purchase` is what a site can answer about itself |
| 21 | * from data it already holds. It is safe on the feature-check path and can exist on Atomic, |
| 22 | * where billing does not ship. Use `Billing_Upgrade` when you need the full purchase record |
| 23 | * and billing is available; use `WPCOM_Site_Purchase` when you are on a site asking what it owns. |
| 24 | */ |
| 25 | class WPCOM_Site_Purchase { |
| 26 | /** |
| 27 | * Object-cache group for billing-derived state. Shared with the site's purchases, which the |
| 28 | * same two hooks already invalidate, and registered as a global group in object-cache.php -- |
| 29 | * the keys carry their own blog ID and are read from whichever blog is current. |
| 30 | */ |
| 31 | private const BILLING_STATES_CACHE_GROUP = 'site_purchases'; |
| 32 | |
| 33 | /** |
| 34 | * How long billing-derived state stays cached. See billing_states_for_blog() for what the TTL |
| 35 | * is actually covering, which is less than it first appears. |
| 36 | */ |
| 37 | private const BILLING_STATES_CACHE_TTL = HOUR_IN_SECONDS; |
| 38 | |
| 39 | /** |
| 40 | * How long a *failed* lookup stays cached. |
| 41 | * |
| 42 | * Far shorter than a successful one. Caching the failure at all is what stops a persistently |
| 43 | * broken billing stack from being asked again on every pageview, but a blip lasting one request |
| 44 | * should not go on being answered for the rest of the hour: while it is cached, both accessors |
| 45 | * answer null and consumers fall back to the raw auto-renew flag -- the very value they exist |
| 46 | * to distrust. |
| 47 | */ |
| 48 | private const BILLING_STATES_FAILURE_TTL = 30; |
| 49 | |
| 50 | /** |
| 51 | * MC stat name under which billing-state lookups are counted, and the bins broken out of it. |
| 52 | * |
| 53 | * Static strings, and within the 32-character limit MC imposes on both a name and a bin. |
| 54 | * Visible at https://mc.a8c.com/s/wpcom-site-purchase/stat-total/ -- and only there until it |
| 55 | * clears 100 hits a day, which is the threshold for the searchable stat list. |
| 56 | */ |
| 57 | private const BILLING_STATES_STAT_NAME = 'wpcom-site-purchase'; |
| 58 | private const BILLING_STATES_STAT_LOOKUP = 'billing-lookup'; |
| 59 | private const BILLING_STATES_STAT_HIT = 'billing-cache-hit'; |
| 60 | private const BILLING_STATES_STAT_ERROR = 'billing-error'; |
| 61 | |
| 62 | /** |
| 63 | * The blog the purchase belongs to. |
| 64 | * |
| 65 | * @var int |
| 66 | */ |
| 67 | public int $blog_id; |
| 68 | |
| 69 | /** |
| 70 | * The product's slug. |
| 71 | * |
| 72 | * @var string |
| 73 | */ |
| 74 | public string $product_slug; |
| 75 | |
| 76 | /** |
| 77 | * The product's ID. |
| 78 | * |
| 79 | * @var string |
| 80 | */ |
| 81 | public string $product_id; |
| 82 | |
| 83 | /** |
| 84 | * The billing product's slug. |
| 85 | * |
| 86 | * @var string |
| 87 | */ |
| 88 | public string $billing_product_slug; |
| 89 | |
| 90 | /** |
| 91 | * The product's type. |
| 92 | * |
| 93 | * @var string |
| 94 | */ |
| 95 | public string $product_type; |
| 96 | |
| 97 | /** |
| 98 | * The ISO 8601 date the purchase was made, carrying an explicit offset rather than |
| 99 | * normalised to UTC. |
| 100 | * |
| 101 | * @var string |
| 102 | */ |
| 103 | public string $subscribed_date; |
| 104 | |
| 105 | /** |
| 106 | * The ISO 8601 date the purchase expires, on the same terms as `$subscribed_date`. |
| 107 | * |
| 108 | * @var string |
| 109 | */ |
| 110 | public string $expiry_date; |
| 111 | |
| 112 | /** |
| 113 | * The subscription's ID. |
| 114 | * |
| 115 | * @var string |
| 116 | */ |
| 117 | public string $subscription_id; |
| 118 | |
| 119 | /** |
| 120 | * Whether the owner has auto-renew switched on. |
| 121 | * |
| 122 | * Both names hold it, populated from whichever one the source provides: Simple sites call it |
| 123 | * `user_allows_auto_renew`, Atomic sites `auto_renew`. |
| 124 | * |
| 125 | * @var bool |
| 126 | */ |
| 127 | public bool $user_allows_auto_renew; |
| 128 | |
| 129 | /** |
| 130 | * Alias of `$user_allows_auto_renew`. |
| 131 | * |
| 132 | * @var bool |
| 133 | */ |
| 134 | public bool $auto_renew; |
| 135 | |
| 136 | /** |
| 137 | * Only synced payloads carry an ownership. |
| 138 | * |
| 139 | * @var string|null |
| 140 | */ |
| 141 | public ?string $ownership_id = null; |
| 142 | |
| 143 | /** |
| 144 | * Billing-derived state, when it arrived with the source. |
| 145 | * |
| 146 | * Private so that it cannot be read without going through the accessors, which know how to |
| 147 | * resolve it when it did not. |
| 148 | * |
| 149 | * @var bool|null |
| 150 | */ |
| 151 | private ?bool $might_still_auto_renew = null; |
| 152 | |
| 153 | /** |
| 154 | * Sibling of `$might_still_auto_renew`; same reason for being private. |
| 155 | * |
| 156 | * @var string|null |
| 157 | */ |
| 158 | private ?string $first_auto_renew_attempt_date = null; |
| 159 | |
| 160 | /** |
| 161 | * Builds from a row of the Simple site store query. |
| 162 | * |
| 163 | * Leaves the two billing-derived fields null on purpose, so the accessors resolve them live. |
| 164 | * They track the passage of time, and the rows this is built from are cached for a day, so a |
| 165 | * value stored here would go stale between refreshes. Only the synced payload carries them |
| 166 | * pre-computed, because an Atomic site has no billing code-base to ask. |
| 167 | * |
| 168 | * Takes the row in whichever shape it arrives, and treats every field as optional. The rows |
| 169 | * are served from an object cache shared across requests, which has been seen to hand back |
| 170 | * arrays rather than the objects the store query returned; an array still carries the whole |
| 171 | * row, so it is read rather than dropped, and an entry that is not a row at all builds an empty |
| 172 | * purchase, which matches no feature. This sits behind a feature check, on nearly every |
| 173 | * request, where being strict about the shape costs the whole site a fatal. |
| 174 | * |
| 175 | * @param mixed $row A row as returned by _wpcom_features_get_simple_site_purchases(), in |
| 176 | * whichever shape it was handed back. |
| 177 | * @param int $blog_id The blog the row belongs to. |
| 178 | */ |
| 179 | public static function from_store_row( $row, int $blog_id ): self { |
| 180 | $row = (object) $row; |
| 181 | $purchase = new self(); |
| 182 | |
| 183 | $purchase->blog_id = $blog_id; |
| 184 | $purchase->product_slug = self::text( $row->product_slug ?? null ); |
| 185 | $purchase->product_id = self::text( $row->product_id ?? null ); |
| 186 | $purchase->billing_product_slug = self::text( $row->billing_product_slug ?? null ); |
| 187 | $purchase->product_type = self::text( $row->product_type ?? null ); |
| 188 | $purchase->subscribed_date = self::text( $row->subscribed_date ?? null ); |
| 189 | $purchase->expiry_date = self::text( $row->expiry_date ?? null ); |
| 190 | $purchase->subscription_id = self::text( $row->subscription_id ?? null ); |
| 191 | $purchase->user_allows_auto_renew = self::flag( $row->user_allows_auto_renew ?? null ); |
| 192 | $purchase->auto_renew = $purchase->user_allows_auto_renew; |
| 193 | |
| 194 | return $purchase; |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * Builds from an entry of the payload synced to an Atomic site. |
| 199 | * |
| 200 | * Every field is optional: a site holds whatever it was last synced with, which may predate |
| 201 | * any given field. |
| 202 | * |
| 203 | * @param object $entry One decoded entry of WPCOM_PURCHASES. |
| 204 | * @param int $blog_id The blog the entry belongs to. |
| 205 | */ |
| 206 | public static function from_synced_payload( object $entry, int $blog_id ): self { |
| 207 | $purchase = new self(); |
| 208 | |
| 209 | $purchase->blog_id = $blog_id; |
| 210 | $purchase->product_slug = self::text( $entry->product_slug ?? null ); |
| 211 | $purchase->product_id = self::text( $entry->product_id ?? null ); |
| 212 | $purchase->billing_product_slug = self::text( $entry->billing_product_slug ?? null ); |
| 213 | $purchase->product_type = self::text( $entry->product_type ?? null ); |
| 214 | $purchase->subscribed_date = self::text( $entry->subscribed_date ?? null ); |
| 215 | $purchase->expiry_date = self::text( $entry->expiry_date ?? null ); |
| 216 | $purchase->subscription_id = self::text( $entry->subscription_id ?? null ); |
| 217 | $purchase->ownership_id = is_scalar( $entry->ownership_id ?? null ) ? (string) $entry->ownership_id : null; |
| 218 | $purchase->user_allows_auto_renew = self::flag( $entry->auto_renew ?? null ); |
| 219 | $purchase->auto_renew = $purchase->user_allows_auto_renew; |
| 220 | |
| 221 | $purchase->might_still_auto_renew = is_bool( $entry->might_still_auto_renew ?? null ) ? $entry->might_still_auto_renew : null; |
| 222 | $purchase->first_auto_renew_attempt_date = is_string( $entry->first_auto_renew_attempt_date ?? null ) ? $entry->first_auto_renew_attempt_date : null; |
| 223 | |
| 224 | return $purchase; |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * Coerces a value to a bool, defaulting anything that is not a scalar. |
| 229 | * |
| 230 | * Sibling of text(), for the same reason: an ordinary cast reads a non-empty array or object |
| 231 | * as `true`, so a field of the wrong shape would arrive claiming auto-renew is on rather than |
| 232 | * defaulting away like every other field. |
| 233 | * |
| 234 | * @param mixed $value Value read off a store row or a decoded payload. |
| 235 | */ |
| 236 | private static function flag( $value ): bool { |
| 237 | return is_scalar( $value ) && (bool) $value; |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * Coerces a value to a string, defaulting anything that is not a scalar. |
| 242 | * |
| 243 | * Guards both constructors against a field arriving with the wrong shape -- an object or array |
| 244 | * where a scalar was expected, which JSON can express trivially and an ordinary cast cannot |
| 245 | * survive. |
| 246 | * |
| 247 | * @param mixed $value Value read off a store row or a decoded payload. |
| 248 | */ |
| 249 | private static function text( $value ): string { |
| 250 | return is_scalar( $value ) ? (string) $value : ''; |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * Whether a renewal will really be attempted, rather than merely having been asked for. |
| 255 | * |
| 256 | * The raw auto-renew flag stays true for subscriptions that cannot renew — one with no |
| 257 | * chargeable payment method attached, for instance — so only billing can answer this. |
| 258 | * Null wherever billing cannot be reached. |
| 259 | * |
| 260 | * NOT A FREE GETTER. On a Simple site whose purchase carries no stored answer this reaches |
| 261 | * billing: a store query, and the whole billing code-base loaded into a request that may |
| 262 | * never have needed it. Cached for an hour, so the repeat cost is a memcache get, but the |
| 263 | * first one is real. Don't call it on a path that runs for every site regardless of expiry; |
| 264 | * gate it on the site actually being close enough to expiry for the answer to matter. |
| 265 | */ |
| 266 | public function might_still_auto_renew(): ?bool { |
| 267 | return $this->might_still_auto_renew ?? $this->billing_state()?->might_still_auto_renew; |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * The UTC date of the first scheduled auto-renewal attempt, as an ISO 8601 string. |
| 272 | * |
| 273 | * The date rather than "is it behind us", because a synced copy is only refreshed on |
| 274 | * subscription events and never on the passage of time; comparing this against the reader's |
| 275 | * own clock stays correct in between. Being scheduled is not a promise that an attempt runs, |
| 276 | * nor that it runs at this time of day. Null either because nothing is scheduled -- no |
| 277 | * meaningful expiry to schedule against, e.g. a VIP domain or a one-time product -- or |
| 278 | * because billing could not be reached; a consumer cannot tell the two apart. |
| 279 | * |
| 280 | * NOT A FREE GETTER. On a Simple site whose purchase carries no stored answer this reaches |
| 281 | * billing: a store query, and the whole billing code-base loaded into a request that may |
| 282 | * never have needed it. Cached for an hour, so the repeat cost is a memcache get, but the |
| 283 | * first one is real. Don't call it on a path that runs for every site regardless of expiry; |
| 284 | * gate it on the site actually being close enough to expiry for the answer to matter. |
| 285 | */ |
| 286 | public function first_auto_renew_attempt_date(): ?string { |
| 287 | return $this->first_auto_renew_attempt_date ?? $this->billing_state()?->first_auto_renew_attempt_date; |
| 288 | } |
| 289 | |
| 290 | /** |
| 291 | * Billing does not necessarily know about every subscription, so this may be null. |
| 292 | */ |
| 293 | private function billing_state(): ?object { |
| 294 | return self::billing_states_for_blog( $this->blog_id )[ $this->subscription_id ] ?? null; |
| 295 | } |
| 296 | |
| 297 | /** |
| 298 | * Billing-derived state for a site's subscriptions, keyed by subscription ID. |
| 299 | * |
| 300 | * Only Simple sites can answer. Answering pulls the entire billing stack into a request that |
| 301 | * may never have loaded it. The billing code-base does not ship everywhere this file runs, and |
| 302 | * a single subscription whose product no longer loads throws for the whole site; both cases |
| 303 | * yield an empty map, so every value must be treated as optional. |
| 304 | * |
| 305 | * Memoized per request, so that a site with many purchases costs one lookup rather than one |
| 306 | * per purchase, and cached in the object cache for an hour on top, so that a careless caller |
| 307 | * costs a memcache get rather than the query and the load. Cleared on `subscription_changed` |
| 308 | * and `wpcom_site_purchases_cleared`, alongside the other `site_purchases` entries. |
| 309 | * |
| 310 | * `subscription_changed` is the one that carries the weight, and it is scoped to the |
| 311 | * subscription row -- which covers more of `might_still_auto_renew` than it looks, since both |
| 312 | * the subscription's status and the `auto_renew` column live there. Everything a user actively |
| 313 | * does is therefore reflected at once, including the case that would otherwise be a support |
| 314 | * ticket: "I re-enabled auto-renew, why is it still nagging me?". |
| 315 | * |
| 316 | * The TTL therefore only has to cover what no subscription event announces, which is two |
| 317 | * things. `is_past_last_auto_renew_attempt_date()` is a clock comparison, but against a |
| 318 | * timestamp pinned to the start of a UTC day, so it flips once, on a day boundary -- a shorter |
| 319 | * TTL would be measuring a day-granular signal to the minute. And the payment method reached |
| 320 | * through `will_auto_renew()` lives in Billingdaddy ownerships rather than on the subscription, |
| 321 | * so a card being replaced, removed, or reaching its own expiry never touches the row and never |
| 322 | * fires the hook. There is no Billingdaddy action to subscribe to for that today; an hour is |
| 323 | * the bound on how stale a notice can be because of it. |
| 324 | * |
| 325 | * `first_auto_renew_attempt_date` needs none of this care: it is a date, and callers compare it |
| 326 | * against their own clock. |
| 327 | * |
| 328 | * @param int $blog_id Blog ID to look up. |
| 329 | * |
| 330 | * @return array Map of subscription ID to an object of derived state. Empty where unavailable. |
| 331 | */ |
| 332 | private static function billing_states_for_blog( int $blog_id ): array { |
| 333 | static $states = array(); |
| 334 | |
| 335 | if ( isset( $states[ $blog_id ] ) ) { |
| 336 | return $states[ $blog_id ]; |
| 337 | } |
| 338 | |
| 339 | $states[ $blog_id ] = array(); |
| 340 | |
| 341 | // The whole loader rather than just the class file, as get_site_billing_upgrades() also |
| 342 | // reaches for store_logger(). Unreadable wherever the billing code-base does not ship. |
| 343 | // Checked before the cache is consulted, so that Atomic -- where this is never readable |
| 344 | // -- pays nothing at all, not even a memcache round trip. |
| 345 | $billing_loader = WP_CONTENT_DIR . '/admin-plugins/wpcom-billing.php'; |
| 346 | if ( ! is_readable( $billing_loader ) ) { |
| 347 | return $states[ $blog_id ]; |
| 348 | } |
| 349 | |
| 350 | $cache_key = self::billing_states_cache_key( $blog_id ); |
| 351 | $cached = wp_cache_get( $cache_key, self::BILLING_STATES_CACHE_GROUP ); |
| 352 | if ( is_array( $cached ) ) { |
| 353 | self::bump_billing_stat( self::BILLING_STATES_STAT_HIT ); |
| 354 | $states[ $blog_id ] = $cached; |
| 355 | return $states[ $blog_id ]; |
| 356 | } |
| 357 | |
| 358 | // Bumped where the work actually happens, so that a consumer which has wandered onto a |
| 359 | // hot path shows up in MC as a spike rather than as a billing incident. |
| 360 | self::bump_billing_stat( self::BILLING_STATES_STAT_LOOKUP ); |
| 361 | |
| 362 | $failed = false; |
| 363 | |
| 364 | try { |
| 365 | require_once $billing_loader; |
| 366 | |
| 367 | // Subscriptions only: skips domain-subscription combining, so every upgrade still |
| 368 | // matches one store subscription. |
| 369 | foreach ( WPCOM_Store_API::get_site_billing_upgrades( $blog_id, true ) as $upgrade ) { |
| 370 | $states[ $blog_id ][ (string) $upgrade->ID ] = (object) array( |
| 371 | 'might_still_auto_renew' => $upgrade->might_still_auto_renew, |
| 372 | 'first_auto_renew_attempt_date' => $upgrade->first_auto_renew_attempt_date, |
| 373 | ); |
| 374 | } |
| 375 | } catch ( \Throwable $e ) { |
| 376 | $states[ $blog_id ] = array(); |
| 377 | $failed = true; |
| 378 | self::bump_billing_stat( self::BILLING_STATES_STAT_ERROR ); |
| 379 | } |
| 380 | |
| 381 | // The empty map is cached too. Reaching billing and coming back with nothing costs the |
| 382 | // same as reaching it and finding something, and a site that has just thrown is exactly |
| 383 | // the one that should not be asked again on the next pageview -- but only briefly, so a |
| 384 | // blip does not keep answering null long after it has passed. |
| 385 | wp_cache_set( |
| 386 | $cache_key, |
| 387 | $states[ $blog_id ], |
| 388 | self::BILLING_STATES_CACHE_GROUP, |
| 389 | $failed ? self::BILLING_STATES_FAILURE_TTL : self::BILLING_STATES_CACHE_TTL |
| 390 | ); |
| 391 | |
| 392 | return $states[ $blog_id ]; |
| 393 | } |
| 394 | |
| 395 | /** |
| 396 | * Object-cache key for a blog's billing-derived state. |
| 397 | * |
| 398 | * Carries the store-subscriptions table name, as the neighbouring `site_purchases` entries |
| 399 | * do, to keep the production and test stores from reading each other's answers. |
| 400 | * |
| 401 | * Public for inspection -- reading the entry by hand while debugging, or asserting on it in a |
| 402 | * test. Invalidation goes through forget_billing_states() instead, so that the group name does |
| 403 | * not have to travel with the key. |
| 404 | * |
| 405 | * WPCOM ONLY, despite living in a class that is otherwise safe on Atomic: there are no store |
| 406 | * tables there, so `$wpdb->store_subscriptions` is not a property and there is nothing to key |
| 407 | * on. Nothing calls this on Atomic today -- billing_states_for_blog() bails before reaching |
| 408 | * it, and the other caller is a wpcom-only mu-plugin -- and nothing should start. |
| 409 | * |
| 410 | * @param int $blog_id Blog ID to look up. |
| 411 | */ |
| 412 | public static function billing_states_cache_key( int $blog_id ): string { |
| 413 | global $wpdb; |
| 414 | |
| 415 | return "billing_states_$blog_id-{$wpdb->store_subscriptions}"; |
| 416 | } |
| 417 | |
| 418 | /** |
| 419 | * Drop a blog's cached billing-derived state. |
| 420 | * |
| 421 | * The whole of the invalidation, so that a caller needs neither the key nor the group and |
| 422 | * cannot drift from either. `clear_wp_cache_site_purchases()` in wpcom-features.php is the |
| 423 | * one that matters; it runs on `subscription_changed` and `wpcom_site_purchases_cleared`. |
| 424 | * |
| 425 | * WPCOM ONLY, for the same reason as the key it builds. |
| 426 | * |
| 427 | * @param int $blog_id Blog whose entry should be forgotten. |
| 428 | */ |
| 429 | public static function forget_billing_states( int $blog_id ): void { |
| 430 | wp_cache_delete( self::billing_states_cache_key( $blog_id ), self::BILLING_STATES_CACHE_GROUP ); |
| 431 | } |
| 432 | |
| 433 | /** |
| 434 | * Count a billing-state lookup in MC. |
| 435 | * |
| 436 | * A counter rather than a log line: this sits behind a feature-check path, where per-event |
| 437 | * logging would be a firehose, and the question worth asking is how often it runs, not which |
| 438 | * request ran it. Best-effort -- stats must never be the reason a page fails to render. |
| 439 | * |
| 440 | * @param string $bin Stat bin to bump. |
| 441 | */ |
| 442 | private static function bump_billing_stat( string $bin ): void { |
| 443 | if ( ! function_exists( 'require_lib' ) ) { |
| 444 | return; |
| 445 | } |
| 446 | |
| 447 | try { |
| 448 | require_lib( 'mc-stats' ); |
| 449 | |
| 450 | if ( function_exists( 'bump_stats_extras' ) ) { |
| 451 | bump_stats_extras( self::BILLING_STATES_STAT_NAME, $bin ); |
| 452 | } |
| 453 | } catch ( \Throwable $e ) { |
| 454 | return; |
| 455 | } |
| 456 | } |
| 457 | } |