Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.11% covered (danger)
36.11%
26 / 72
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Site_Purchase
36.11% covered (danger)
36.11%
26 / 72
50.00% covered (danger)
50.00%
5 / 10
160.95
0.00% covered (danger)
0.00%
0 / 1
 from_store_row
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 from_synced_payload
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 text
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 might_still_auto_renew
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 first_auto_renew_attempt_date
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 billing_state
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 billing_states_for_blog
21.88% covered (danger)
21.88%
7 / 32
0.00% covered (danger)
0.00%
0 / 1
30.37
 billing_states_cache_key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 forget_billing_states
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 bump_billing_stat
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
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 */
25class 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     * @param object $row     A row as returned by _wpcom_features_get_simple_site_purchases().
169     * @param int    $blog_id The blog the row belongs to.
170     */
171    public static function from_store_row( object $row, int $blog_id ): self {
172        $purchase = new self();
173
174        $purchase->blog_id                = $blog_id;
175        $purchase->product_slug           = (string) $row->product_slug;
176        $purchase->product_id             = (string) $row->product_id;
177        $purchase->billing_product_slug   = (string) $row->billing_product_slug;
178        $purchase->product_type           = (string) $row->product_type;
179        $purchase->subscribed_date        = (string) $row->subscribed_date;
180        $purchase->expiry_date            = (string) $row->expiry_date;
181        $purchase->subscription_id        = (string) $row->subscription_id;
182        $purchase->user_allows_auto_renew = (bool) $row->user_allows_auto_renew;
183        $purchase->auto_renew             = $purchase->user_allows_auto_renew;
184
185        return $purchase;
186    }
187
188    /**
189     * Builds from an entry of the payload synced to an Atomic site.
190     *
191     * Every field is optional: a site holds whatever it was last synced with, which may predate
192     * any given field.
193     *
194     * @param object $entry   One decoded entry of WPCOM_PURCHASES.
195     * @param int    $blog_id The blog the entry belongs to.
196     */
197    public static function from_synced_payload( object $entry, int $blog_id ): self {
198        $purchase = new self();
199
200        $purchase->blog_id                = $blog_id;
201        $purchase->product_slug           = self::text( $entry->product_slug ?? null );
202        $purchase->product_id             = self::text( $entry->product_id ?? null );
203        $purchase->billing_product_slug   = self::text( $entry->billing_product_slug ?? null );
204        $purchase->product_type           = self::text( $entry->product_type ?? null );
205        $purchase->subscribed_date        = self::text( $entry->subscribed_date ?? null );
206        $purchase->expiry_date            = self::text( $entry->expiry_date ?? null );
207        $purchase->subscription_id        = self::text( $entry->subscription_id ?? null );
208        $purchase->ownership_id           = is_scalar( $entry->ownership_id ?? null ) ? (string) $entry->ownership_id : null;
209        $purchase->user_allows_auto_renew = (bool) ( $entry->auto_renew ?? false );
210        $purchase->auto_renew             = $purchase->user_allows_auto_renew;
211
212        $purchase->might_still_auto_renew        = is_bool( $entry->might_still_auto_renew ?? null ) ? $entry->might_still_auto_renew : null;
213        $purchase->first_auto_renew_attempt_date = is_string( $entry->first_auto_renew_attempt_date ?? null ) ? $entry->first_auto_renew_attempt_date : null;
214
215        return $purchase;
216    }
217
218    /**
219     * Coerces a decoded JSON value to a string, defaulting anything that is not a scalar.
220     *
221     * Guards from_synced_payload() against a field arriving with the wrong shape -- an object or
222     * array where a scalar was expected, which JSON can express trivially and an ordinary cast
223     * cannot survive.
224     *
225     * @param mixed $value Decoded JSON value.
226     */
227    private static function text( $value ): string {
228        return is_scalar( $value ) ? (string) $value : '';
229    }
230
231    /**
232     * Whether a renewal will really be attempted, rather than merely having been asked for.
233     *
234     * The raw auto-renew flag stays true for subscriptions that cannot renew — one with no
235     * chargeable payment method attached, for instance — so only billing can answer this.
236     * Null wherever billing cannot be reached.
237     *
238     * NOT A FREE GETTER. On a Simple site whose purchase carries no stored answer this reaches
239     * billing: a store query, and the whole billing code-base loaded into a request that may
240     * never have needed it. Cached for an hour, so the repeat cost is a memcache get, but the
241     * first one is real. Don't call it on a path that runs for every site regardless of expiry;
242     * gate it on the site actually being close enough to expiry for the answer to matter.
243     */
244    public function might_still_auto_renew(): ?bool {
245        return $this->might_still_auto_renew ?? $this->billing_state()?->might_still_auto_renew;
246    }
247
248    /**
249     * The UTC date of the first scheduled auto-renewal attempt, as an ISO 8601 string.
250     *
251     * The date rather than "is it behind us", because a synced copy is only refreshed on
252     * subscription events and never on the passage of time; comparing this against the reader's
253     * own clock stays correct in between. Being scheduled is not a promise that an attempt runs,
254     * nor that it runs at this time of day. Null either because nothing is scheduled -- no
255     * meaningful expiry to schedule against, e.g. a VIP domain or a one-time product -- or
256     * because billing could not be reached; a consumer cannot tell the two apart.
257     *
258     * NOT A FREE GETTER. On a Simple site whose purchase carries no stored answer this reaches
259     * billing: a store query, and the whole billing code-base loaded into a request that may
260     * never have needed it. Cached for an hour, so the repeat cost is a memcache get, but the
261     * first one is real. Don't call it on a path that runs for every site regardless of expiry;
262     * gate it on the site actually being close enough to expiry for the answer to matter.
263     */
264    public function first_auto_renew_attempt_date(): ?string {
265        return $this->first_auto_renew_attempt_date ?? $this->billing_state()?->first_auto_renew_attempt_date;
266    }
267
268    /**
269     * Billing does not necessarily know about every subscription, so this may be null.
270     */
271    private function billing_state(): ?object {
272        return self::billing_states_for_blog( $this->blog_id )[ $this->subscription_id ] ?? null;
273    }
274
275    /**
276     * Billing-derived state for a site's subscriptions, keyed by subscription ID.
277     *
278     * Only Simple sites can answer. Answering pulls the entire billing stack into a request that
279     * may never have loaded it. The billing code-base does not ship everywhere this file runs, and
280     * a single subscription whose product no longer loads throws for the whole site; both cases
281     * yield an empty map, so every value must be treated as optional.
282     *
283     * Memoized per request, so that a site with many purchases costs one lookup rather than one
284     * per purchase, and cached in the object cache for an hour on top, so that a careless caller
285     * costs a memcache get rather than the query and the load. Cleared on `subscription_changed`
286     * and `wpcom_site_purchases_cleared`, alongside the other `site_purchases` entries.
287     *
288     * `subscription_changed` is the one that carries the weight, and it is scoped to the
289     * subscription row -- which covers more of `might_still_auto_renew` than it looks, since both
290     * the subscription's status and the `auto_renew` column live there. Everything a user actively
291     * does is therefore reflected at once, including the case that would otherwise be a support
292     * ticket: "I re-enabled auto-renew, why is it still nagging me?".
293     *
294     * The TTL therefore only has to cover what no subscription event announces, which is two
295     * things. `is_past_last_auto_renew_attempt_date()` is a clock comparison, but against a
296     * timestamp pinned to the start of a UTC day, so it flips once, on a day boundary -- a shorter
297     * TTL would be measuring a day-granular signal to the minute. And the payment method reached
298     * through `will_auto_renew()` lives in Billingdaddy ownerships rather than on the subscription,
299     * so a card being replaced, removed, or reaching its own expiry never touches the row and never
300     * fires the hook. There is no Billingdaddy action to subscribe to for that today; an hour is
301     * the bound on how stale a notice can be because of it.
302     *
303     * `first_auto_renew_attempt_date` needs none of this care: it is a date, and callers compare it
304     * against their own clock.
305     *
306     * @param int $blog_id Blog ID to look up.
307     *
308     * @return array Map of subscription ID to an object of derived state. Empty where unavailable.
309     */
310    private static function billing_states_for_blog( int $blog_id ): array {
311        static $states = array();
312
313        if ( isset( $states[ $blog_id ] ) ) {
314            return $states[ $blog_id ];
315        }
316
317        $states[ $blog_id ] = array();
318
319        // The whole loader rather than just the class file, as get_site_billing_upgrades() also
320        // reaches for store_logger(). Unreadable wherever the billing code-base does not ship.
321        // Checked before the cache is consulted, so that Atomic -- where this is never readable
322        // -- pays nothing at all, not even a memcache round trip.
323        $billing_loader = WP_CONTENT_DIR . '/admin-plugins/wpcom-billing.php';
324        if ( ! is_readable( $billing_loader ) ) {
325            return $states[ $blog_id ];
326        }
327
328        $cache_key = self::billing_states_cache_key( $blog_id );
329        $cached    = wp_cache_get( $cache_key, self::BILLING_STATES_CACHE_GROUP );
330        if ( is_array( $cached ) ) {
331            self::bump_billing_stat( self::BILLING_STATES_STAT_HIT );
332            $states[ $blog_id ] = $cached;
333            return $states[ $blog_id ];
334        }
335
336        // Bumped where the work actually happens, so that a consumer which has wandered onto a
337        // hot path shows up in MC as a spike rather than as a billing incident.
338        self::bump_billing_stat( self::BILLING_STATES_STAT_LOOKUP );
339
340        $failed = false;
341
342        try {
343            require_once $billing_loader;
344
345            // Subscriptions only: skips domain-subscription combining, so every upgrade still
346            // matches one store subscription.
347            foreach ( WPCOM_Store_API::get_site_billing_upgrades( $blog_id, true ) as $upgrade ) {
348                $states[ $blog_id ][ (string) $upgrade->ID ] = (object) array(
349                    'might_still_auto_renew'        => $upgrade->might_still_auto_renew,
350                    'first_auto_renew_attempt_date' => $upgrade->first_auto_renew_attempt_date,
351                );
352            }
353        } catch ( \Throwable $e ) {
354            $states[ $blog_id ] = array();
355            $failed             = true;
356            self::bump_billing_stat( self::BILLING_STATES_STAT_ERROR );
357        }
358
359        // The empty map is cached too. Reaching billing and coming back with nothing costs the
360        // same as reaching it and finding something, and a site that has just thrown is exactly
361        // the one that should not be asked again on the next pageview -- but only briefly, so a
362        // blip does not keep answering null long after it has passed.
363        wp_cache_set(
364            $cache_key,
365            $states[ $blog_id ],
366            self::BILLING_STATES_CACHE_GROUP,
367            $failed ? self::BILLING_STATES_FAILURE_TTL : self::BILLING_STATES_CACHE_TTL
368        );
369
370        return $states[ $blog_id ];
371    }
372
373    /**
374     * Object-cache key for a blog's billing-derived state.
375     *
376     * Carries the store-subscriptions table name, as the neighbouring `site_purchases` entries
377     * do, to keep the production and test stores from reading each other's answers.
378     *
379     * Public for inspection -- reading the entry by hand while debugging, or asserting on it in a
380     * test. Invalidation goes through forget_billing_states() instead, so that the group name does
381     * not have to travel with the key.
382     *
383     * WPCOM ONLY, despite living in a class that is otherwise safe on Atomic: there are no store
384     * tables there, so `$wpdb->store_subscriptions` is not a property and there is nothing to key
385     * on. Nothing calls this on Atomic today -- billing_states_for_blog() bails before reaching
386     * it, and the other caller is a wpcom-only mu-plugin -- and nothing should start.
387     *
388     * @param int $blog_id Blog ID to look up.
389     */
390    public static function billing_states_cache_key( int $blog_id ): string {
391        global $wpdb;
392
393        return "billing_states_$blog_id-{$wpdb->store_subscriptions}";
394    }
395
396    /**
397     * Drop a blog's cached billing-derived state.
398     *
399     * The whole of the invalidation, so that a caller needs neither the key nor the group and
400     * cannot drift from either. `clear_wp_cache_site_purchases()` in wpcom-features.php is the
401     * one that matters; it runs on `subscription_changed` and `wpcom_site_purchases_cleared`.
402     *
403     * WPCOM ONLY, for the same reason as the key it builds.
404     *
405     * @param int $blog_id Blog whose entry should be forgotten.
406     */
407    public static function forget_billing_states( int $blog_id ): void {
408        wp_cache_delete( self::billing_states_cache_key( $blog_id ), self::BILLING_STATES_CACHE_GROUP );
409    }
410
411    /**
412     * Count a billing-state lookup in MC.
413     *
414     * A counter rather than a log line: this sits behind a feature-check path, where per-event
415     * logging would be a firehose, and the question worth asking is how often it runs, not which
416     * request ran it. Best-effort -- stats must never be the reason a page fails to render.
417     *
418     * @param string $bin Stat bin to bump.
419     */
420    private static function bump_billing_stat( string $bin ): void {
421        if ( ! function_exists( 'require_lib' ) ) {
422            return;
423        }
424
425        try {
426            require_lib( 'mc-stats' );
427
428            if ( function_exists( 'bump_stats_extras' ) ) {
429                bump_stats_extras( self::BILLING_STATES_STAT_NAME, $bin );
430            }
431        } catch ( \Throwable $e ) {
432            return;
433        }
434    }
435}