PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.0.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.0.0
2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / includes / Illuminate / Stats.php

Stats.php in Subscriptions for WooCommerce with Stripe Recurring Payments 2.0.0, at includes/Illuminate/Stats.php

474 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace SpringDevs\Subscription\Illuminate;
4
5 use SpringDevs\Subscription\Installer;
6
7 /**
8 * Collects subscription statistics over time.
9 *
10 * Computes monthly recurring revenue (MRR) and status counts, and writes one
11 * snapshot per calendar day into {prefix}subscrpt_stats_snapshot so reports
12 * (free, pro) and the recovery add-on can chart MRR/subscriptions over time.
13 *
14 * The snapshot runs at most once per day, triggered by the hourly cron and,
15 * as a low-traffic-site safety net, on admin page loads. The heavy aggregation
16 * therefore happens only once daily regardless of trigger.
17 *
18 * @package SpringDevs\Subscription\Illuminate
19 */
20 class Stats {
21
22 /**
23 * Option key holding the last snapshot date (Y-m-d, UTC).
24 *
25 * @var string
26 */
27 const LAST_SNAPSHOT_OPTION = 'subscrpt_stats_last_snapshot';
28
29 /**
30 * Months represented by one unit of each billing period (for MRR).
31 *
32 * @var array<string,float>
33 */
34 const MONTHS_PER_UNIT = array(
35 'day' => 0.03333333333,
36 'week' => 0.23333333333,
37 'month' => 1.0,
38 'year' => 12.0,
39 );
40
41 /**
42 * Initialize the class.
43 */
44 public function __construct() {
45 // Self-heal the snapshot table for installs that updated without reactivating.
46 Installer::maybe_upgrade();
47
48 add_action( 'subscrpt_hourly_cron', array( $this, 'maybe_take_daily_snapshot' ) );
49 add_action( 'admin_init', array( $this, 'maybe_take_daily_snapshot' ) );
50
51 // A cached monthly total that ignores the sale that just happened is
52 // worse than no cache: the figure is wrong and nothing says so. Any
53 // order changing status can move a month's revenue in or out.
54 add_action( 'woocommerce_order_status_changed', array( __CLASS__, 'flush_monthly_revenue' ) );
55 }
56
57 /**
58 * Drop the cached monthly revenue.
59 *
60 * @return void
61 */
62 public static function flush_monthly_revenue() {
63 for ( $months = 1; $months <= 24; $months++ ) {
64 delete_transient( 'subscrpt_monthly_revenue_' . $months );
65 }
66 }
67
68 /**
69 * Normalize a recurring amount to a monthly figure (MRR).
70 *
71 * mrr = amount / (interval * months_per_unit[period]). Unknown periods are
72 * treated as monthly; a zero cycle returns 0 to avoid division by zero.
73 *
74 * @param float $amount Raw recurring price.
75 * @param string $period Billing period: day|week|month|year.
76 * @param int $interval Billing interval (the "every N").
77 * @return float Monthly-normalized amount.
78 */
79 public static function normalize_mrr( $amount, $period, $interval ) {
80 $months_per_unit = isset( self::MONTHS_PER_UNIT[ $period ] ) ? self::MONTHS_PER_UNIT[ $period ] : 1.0;
81 $cycle_in_months = $interval * $months_per_unit;
82
83 if ( $cycle_in_months <= 0 ) {
84 return 0.0;
85 }
86
87 return round( $amount / $cycle_in_months, 2 );
88 }
89
90 /**
91 * Total monthly recurring revenue across all active subscriptions.
92 *
93 * @return float
94 */
95 public static function calculate_active_mrr() {
96 $ids = Helper::get_subscriptions(
97 array(
98 'status' => 'active',
99 'user_id' => -1,
100 'return' => 'ids',
101 'posts_per_page' => -1,
102 )
103 );
104
105 $total = 0.0;
106
107 foreach ( (array) $ids as $id ) {
108 $amount = (float) get_post_meta( $id, '_subscrpt_price', true );
109
110 $product_id = (int) get_post_meta( $id, '_subscrpt_product_id', true );
111 $variation_id = (int) get_post_meta( $id, '_subscrpt_variation_id', true );
112 $fallback_id = $variation_id ? $variation_id : $product_id;
113
114 $period = get_post_meta( $id, '_subscrpt_timing_option', true );
115 $period = $period ? (string) $period : get_post_meta( $fallback_id, '_subscrpt_timing_option', true );
116 $period = $period ? $period : 'month';
117
118 $interval = get_post_meta( $id, '_subscrpt_timing_per', true );
119 $interval = ! empty( $interval ) ? $interval : get_post_meta( $fallback_id, '_subscrpt_timing_per', true );
120 $interval = max( 1, (int) $interval );
121
122 $total += self::normalize_mrr( $amount, $period, $interval );
123 }
124
125 return round( $total, 2 );
126 }
127
128 /**
129 * Count subscriptions per status.
130 *
131 * @return array<string,int> Keyed by status (active, pending, ...).
132 */
133 public static function get_status_counts() {
134 $counts = wp_count_posts( 'subscrpt_order' );
135 $statuses = array( 'active', 'pending', 'on_hold', 'cancelled', 'expired', 'completed', 'pe_cancelled' );
136 $out = array();
137
138 foreach ( $statuses as $status ) {
139 $out[ $status ] = isset( $counts->$status ) ? (int) $counts->$status : 0;
140 }
141
142 return $out;
143 }
144
145 /**
146 * Query arguments for active subscriptions whose next payment falls within
147 * the next N days.
148 *
149 * The one definition of "renewals due": the Overview counts with it and the
150 * subscriptions list filters with it, so the figure and the rows it opens
151 * cannot disagree. The meta clause is named so the list can sort by it.
152 *
153 * `_subscrpt_next_date` holds a Unix timestamp, so the window is compared
154 * numerically rather than as a date string.
155 *
156 * @param int $days Number of days ahead to look.
157 * @return array<string,mixed> WP_Query arguments.
158 */
159 public static function renewals_due_args( int $days = 7 ): array {
160 $now = time();
161
162 return array(
163 'post_type' => 'subscrpt_order',
164 'post_status' => 'active',
165 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
166 'subscrpt_next_date' => array(
167 'key' => '_subscrpt_next_date',
168 'value' => array( $now, $now + ( max( 1, $days ) * DAY_IN_SECONDS ) ),
169 'compare' => 'BETWEEN',
170 'type' => 'NUMERIC',
171 ),
172 ),
173 );
174 }
175
176 /**
177 * Count active subscriptions whose next payment falls inside a window.
178 *
179 * @param int $days Number of days ahead to look.
180 * @return int
181 */
182 public static function count_renewals_due_within( int $days = 7 ): int {
183 $query = new \WP_Query(
184 array_merge(
185 self::renewals_due_args( $days ),
186 array(
187 'fields' => 'ids',
188 'posts_per_page' => 1,
189 )
190 )
191 );
192
193 return (int) $query->found_posts;
194 }
195
196 /**
197 * Count renewal orders that failed recently.
198 *
199 * Renewal orders are identified from the subscription relation table rather
200 * than from order meta, then looked up through `wc_get_orders()` — reading
201 * the posts table directly would return nothing on a store using HPOS.
202 *
203 * @param int $hours How far back to look.
204 * @return int
205 */
206 public static function count_failed_renewals_since( int $hours = 24 ): int {
207 global $wpdb;
208
209 if ( ! function_exists( 'wc_get_orders' ) ) {
210 return 0;
211 }
212
213 $since = time() - ( max( 1, $hours ) * HOUR_IN_SECONDS );
214
215 /*
216 * Ask WooCommerce first, not the relation table.
217 *
218 * The relation table holds every renewal order ever created, so starting
219 * there means pulling an unbounded id list out of a store's whole
220 * history and handing it to wc_get_orders(). Starting from the orders
221 * side bounds the set by the time window before anything else runs —
222 * usually a handful of rows — and only those ids reach the second query.
223 *
224 * wc_get_orders() rather than SQL against posts, because a store on HPOS
225 * keeps orders in their own tables and a posts query returns nothing.
226 */
227 $failed = wc_get_orders(
228 array(
229 'status' => array( 'failed' ),
230 'date_modified' => '>' . $since,
231 'limit' => -1,
232 'return' => 'ids',
233 )
234 );
235
236 $failed = array_filter( array_map( 'intval', (array) $failed ) );
237
238 if ( empty( $failed ) ) {
239 return 0;
240 }
241
242 $table = $wpdb->prefix . 'subscrpt_order_relation';
243 $placeholders = implode( ',', array_fill( 0, count( $failed ), '%d' ) );
244
245 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from the prefix; ids are placeheld below.
246 $sql = "SELECT COUNT( DISTINCT order_id ) FROM {$table} WHERE type = 'renew' AND order_id IN ( {$placeholders} )";
247
248 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared immediately above.
249 return (int) $wpdb->get_var( $wpdb->prepare( $sql, $failed ) );
250 }
251
252 /**
253 * Count subscriptions created within the last N days.
254 *
255 * @param int $days Number of days back to look.
256 * @return int
257 */
258 public static function count_new_since( int $days = 7 ): int {
259 global $wpdb;
260
261 $since = gmdate( 'Y-m-d H:i:s', time() - ( max( 1, $days ) * DAY_IN_SECONDS ) );
262
263 return (int) $wpdb->get_var(
264 $wpdb->prepare(
265 "SELECT COUNT(1) FROM {$wpdb->posts}
266 WHERE post_type = 'subscrpt_order'
267 AND post_status NOT IN ( 'trash', 'auto-draft' )
268 AND post_date_gmt >= %s",
269 $since
270 )
271 );
272 }
273
274 /**
275 * Count subscriptions created in one calendar month.
276 *
277 * Asks exactly what the subscriptions list asks when filtered to that month
278 * — the same statuses, the same local post date — so a figure that links to
279 * the filtered list always matches the rows it opens.
280 *
281 * @param \DateTimeInterface $month Any moment in the month, in the store's timezone.
282 * @return int
283 */
284 public static function count_new_in_month( \DateTimeInterface $month ): int {
285 $query = new \WP_Query(
286 array(
287 'post_type' => 'subscrpt_order',
288 'post_status' => 'any',
289 'date_query' => array(
290 array(
291 'year' => (int) $month->format( 'Y' ),
292 'month' => (int) $month->format( 'n' ),
293 ),
294 ),
295 'fields' => 'ids',
296 'posts_per_page' => 1,
297 )
298 );
299
300 return (int) $query->found_posts;
301 }
302
303 /**
304 * Revenue from subscription orders, grouped by month.
305 *
306 * Read from real orders rather than the snapshot table: snapshots record
307 * counts and MRR from the day this plugin started taking them, so a store
308 * that installed last week has no history to chart. Orders go back as far
309 * as the store does.
310 *
311 * Cached, because this is the one figure on the dashboard that does not
312 * change minute to minute and the only one whose cost grows with the size
313 * of the store.
314 *
315 * @param int $months How many months to return, including the current one.
316 * @return array<int,array{label:string,month:string,total:float}> Oldest first.
317 */
318 public static function get_monthly_revenue( int $months = 6 ): array {
319 global $wpdb;
320
321 $months = max( 1, min( 24, $months ) );
322 $key = 'subscrpt_monthly_revenue_' . $months;
323 $cached = get_transient( $key );
324
325 if ( is_array( $cached ) ) {
326 return $cached;
327 }
328
329 // Every month in the window, so a month with no sales is a gap in the
330 // chart rather than a missing bar that shifts everything along.
331 //
332 // The store's months, not UTC's. WooCommerce dates an order in the store
333 // timezone, so UTC buckets have no bar for an order placed between UTC
334 // and local midnight on the 1st, and it silently drops out of the chart.
335 $this_month = ( new \DateTimeImmutable( 'now', wp_timezone() ) )->modify( 'first day of this month' )->setTime( 0, 0 );
336 $first = $this_month->modify( '-' . ( $months - 1 ) . ' months' );
337
338 $buckets = array();
339 for ( $i = $months - 1; $i >= 0; $i-- ) {
340 $month = $this_month->modify( "-{$i} months" );
341
342 $buckets[ $month->format( 'Y-m' ) ] = array(
343 'label' => wp_date( 'M', $month->getTimestamp() ),
344 'month' => $month->format( 'Y-m' ),
345 'total' => 0.0,
346 );
347 }
348
349 if ( ! function_exists( 'wc_get_orders' ) ) {
350 return array_values( $buckets );
351 }
352
353 $table = $wpdb->prefix . 'subscrpt_order_relation';
354
355 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from the prefix.
356 $order_ids = $wpdb->get_col( "SELECT DISTINCT order_id FROM {$table}" );
357 $order_ids = array_filter( array_map( 'intval', (array) $order_ids ) );
358
359 if ( ! empty( $order_ids ) ) {
360 $orders = wc_get_orders(
361 array(
362 'post__in' => $order_ids,
363 'status' => array( 'completed', 'processing' ),
364 // A timestamp, not a date string: WooCommerce keeps only the day
365 // of a string and reads it in the store timezone, while a
366 // timestamp is compared to the second.
367 'date_created' => '>=' . $first->getTimestamp(),
368 'limit' => -1,
369 )
370 );
371
372 foreach ( (array) $orders as $order ) {
373 $created = $order->get_date_created();
374
375 if ( ! $created ) {
376 continue;
377 }
378
379 $bucket = $created->date( 'Y-m' );
380
381 if ( isset( $buckets[ $bucket ] ) ) {
382 $buckets[ $bucket ]['total'] += (float) $order->get_total();
383 }
384 }
385 }
386
387 $out = array_values( $buckets );
388
389 set_transient( $key, $out, 6 * HOUR_IN_SECONDS );
390
391 return $out;
392 }
393
394 /**
395 * Take today's snapshot unless one already exists for today.
396 *
397 * @return void
398 */
399 public function maybe_take_daily_snapshot() {
400 $today = gmdate( 'Y-m-d' );
401
402 if ( get_option( self::LAST_SNAPSHOT_OPTION ) === $today ) {
403 return;
404 }
405
406 $this->take_snapshot( $today );
407 update_option( self::LAST_SNAPSHOT_OPTION, $today );
408 }
409
410 /**
411 * Compute and persist a snapshot row for the given date.
412 *
413 * @param string $date Snapshot date (Y-m-d, UTC). Defaults to today.
414 * @return void
415 */
416 public function take_snapshot( $date = '' ) {
417 global $wpdb;
418
419 $date = $date ? $date : gmdate( 'Y-m-d' );
420 $counts = self::get_status_counts();
421
422 $wpdb->replace(
423 $wpdb->prefix . 'subscrpt_stats_snapshot',
424 array(
425 'snapshot_date' => $date,
426 'active_count' => $counts['active'],
427 'pending_count' => $counts['pending'],
428 'on_hold_count' => $counts['on_hold'],
429 'cancelled_count' => $counts['cancelled'],
430 'expired_count' => $counts['expired'],
431 'pe_cancelled_count' => $counts['pe_cancelled'],
432 'active_mrr' => self::calculate_active_mrr(),
433 'created_at' => current_time( 'mysql', true ),
434 ),
435 array( '%s', '%d', '%d', '%d', '%d', '%d', '%d', '%f', '%s' )
436 );
437 }
438
439 /**
440 * Read snapshot rows within a date range (inclusive), oldest first.
441 *
442 * @param string $from Start date (Y-m-d). Empty for no lower bound.
443 * @param string $to End date (Y-m-d). Empty for no upper bound.
444 * @return array<int,object> Snapshot rows.
445 */
446 public static function get_snapshots( $from = '', $to = '' ) {
447 global $wpdb;
448
449 $table = $wpdb->prefix . 'subscrpt_stats_snapshot';
450 $where = '1=1';
451 $args = array();
452
453 if ( $from ) {
454 $where .= ' AND snapshot_date >= %s';
455 $args[] = $from;
456 }
457
458 if ( $to ) {
459 $where .= ' AND snapshot_date <= %s';
460 $args[] = $to;
461 }
462
463 $sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY snapshot_date ASC";
464
465 if ( $args ) {
466 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
467 $sql = $wpdb->prepare( $sql, $args );
468 }
469
470 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
471 return $wpdb->get_results( $sql );
472 }
473 }
474