recurring_revenue()['mrr']; } /** * How many live subscriptions MRR had to leave out. */ public function excluded_currency_count(): int { return $this->recurring_revenue()['excluded']; } /** * Only subscriptions already in the store's currency are summed. * * Converting the rest would mean inventing an exchange rate, and adding them raw * would mean adding yen to euros. They are counted and reported instead, so the * figure is understated in a way the merchant can see rather than wrong silently. * * @return array{mrr: Money, excluded: int} */ private function recurring_revenue(): array { $store = get_woocommerce_currency(); $total = Money::zero( $store ); $excluded = 0; foreach ( Subscription_Query::get( array( 'status' => $this->live_statuses(), 'limit' => -1, ) ) as $subscription ) { if ( ! $subscription instanceof Subscription ) { continue; } if ( $subscription->get_currency() !== $store ) { ++$excluded; continue; } $total = $total->add( $this->monthly_value( $subscription ) ); } return array( 'mrr' => $total, 'excluded' => $excluded, ); } /** * A subscription's value normalised to one month, so a yearly plan and a weekly one * can be added together at all. */ public function monthly_value( Subscription $subscription ): Money { // Revenue is reported without tax, so the figure is the same whichever way the store enters prices. $total = Money::from_decimal( $subscription->get_total(), $subscription->get_currency() ) ->subtract( Money::from_decimal( $subscription->get_total_tax(), $subscription->get_currency() ) ); if ( $total->is_zero() ) { return $total; } $interval = max( 1, $subscription->get_billing_interval() ); $days = match ( (string) $subscription->get_billing_period() ) { 'day' => 1, 'week' => 7, 'year' => self::DAYS_IN_YEAR, default => self::DAYS_IN_MONTH, } * $interval; return Money::from_minor( (int) round( $total->minor() * ( self::DAYS_IN_MONTH / $days ) ), $total->currency(), $total->decimals() ); } /** * @return array status slug => count */ public function counts_by_status(): array { $counts = array(); foreach ( Subscription_Status::cases() as $status ) { $counts[ $status->value ] = count( Subscription_Query::ids( array( 'status' => $status->value, 'limit' => -1, ) ) ); } return $counts; } public function active_count(): int { return count( Subscription_Query::ids( array( 'status' => $this->live_statuses(), 'limit' => -1, ) ) ); } /** * One row per day. Re-running on the same day overwrites rather than appends, so a * manual run or a retried job cannot double-count. */ public function take_snapshot( string $date = '' ): void { $date = '' === $date ? gmdate( 'Y-m-d' ) : $date; $history = $this->history(); $history[ $date ] = array( 'mrr' => $this->mrr()->minor(), 'active' => $this->active_count(), ); ksort( $history ); // Bounded on purpose: an option that only ever grows is autoloaded on every // request forever. if ( count( $history ) > self::KEEP_DAYS ) { $history = array_slice( $history, -self::KEEP_DAYS, null, true ); } update_option( self::OPTION_HISTORY, $history, false ); } /** * @return array */ public function history( int $days = 0 ): array { $history = get_option( self::OPTION_HISTORY, array() ); $history = is_array( $history ) ? $history : array(); return $days > 0 ? array_slice( $history, -$days, null, true ) : $history; } /** * @return string[] */ private function live_statuses(): array { return array( Subscription_Status::Active->value, Subscription_Status::Trialling->value, Subscription_Status::PendingCancel->value, ); } }