Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.05% covered (warning)
54.05%
40 / 74
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Site_Purchase
54.05% covered (warning)
54.05%
40 / 74
63.64% covered (warning)
63.64%
7 / 11
85.62
0.00% covered (danger)
0.00%
0 / 1
 from_store_row
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 from_synced_payload
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 flag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 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     * 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}