diff --git a/app/Console/Commands/StripeSubscriptionStatsCommand.php b/app/Console/Commands/StripeSubscriptionStatsCommand.php new file mode 100644 index 00000000..16d3c3bc --- /dev/null +++ b/app/Console/Commands/StripeSubscriptionStatsCommand.php @@ -0,0 +1,139 @@ + + */ + private array $active = []; + + /** + * @var array + */ + private array $trialing = []; + + public function handle(): int + { + try { + $this->collect('active', $this->active); + $this->collect('trialing', $this->trialing); + } catch (ApiErrorException $exception) { + $this->error("Stripe API error: {$exception->getMessage()}"); + + return self::FAILURE; + } + + $this->render(); + + return self::SUCCESS; + } + + /** + * @param array $bucket + * + * @throws ApiErrorException + */ + private function collect(string $status, array &$bucket): void + { + $subscriptions = Cashier::stripe()->subscriptions->all([ + 'status' => $status, + 'limit' => 100, + 'expand' => ['data.items.data.price'], + ]); + + /** @var Subscription $subscription */ + foreach ($subscriptions->autoPagingIterator() as $subscription) { + $currency = strtolower((string) $subscription->currency); + + $bucket[$currency] ??= ['count' => 0, 'mrr' => 0.0]; + $bucket[$currency]['count']++; + $bucket[$currency]['mrr'] += $this->monthlyValue($subscription); + } + } + + private function monthlyValue(Subscription $subscription): float + { + $monthly = 0.0; + + foreach ($subscription->items->data as $item) { + $price = $item->price; + + if ($price->recurring === null) { + continue; + } + + $amount = ($price->unit_amount ?? 0) / 100; + $quantity = $item->quantity ?? 1; + $intervalCount = $price->recurring->interval_count ?: 1; + + $perMonth = match ($price->recurring->interval) { + 'day' => $amount * 365 / 12, + 'week' => $amount * 52 / 12, + 'month' => $amount, + 'year' => $amount / 12, + default => 0.0, + }; + + $monthly += ($perMonth / $intervalCount) * $quantity; + } + + return $monthly; + } + + private function render(): void + { + $currencies = array_unique(array_merge(array_keys($this->active), array_keys($this->trialing))); + sort($currencies); + + if ($currencies === []) { + $this->warn('No active or trialing subscriptions found.'); + + return; + } + + foreach ($currencies as $currency) { + $active = $this->active[$currency] ?? ['count' => 0, 'mrr' => 0.0]; + $trialing = $this->trialing[$currency] ?? ['count' => 0, 'mrr' => 0.0]; + + $currentMrr = $active['mrr']; + $projectedMrr = $active['mrr'] + $trialing['mrr']; + + $this->newLine(); + $this->line(''.strtoupper($currency).''); + $this->line(" Active subs: {$active['count']} ({$this->format($active['mrr'], $currency)} MRR)"); + $this->line(" Trialing subs: {$trialing['count']} ({$this->format($trialing['mrr'], $currency)} MRR)"); + $this->newLine(); + $this->line(" Current MRR: {$this->format($currentMrr, $currency)}"); + $this->line(" Current ARR: {$this->format($currentMrr * 12, $currency)}"); + $this->line(" Projected MRR: {$this->format($projectedMrr, $currency)} (if trialing convert)"); + $this->line(" Projected ARR: {$this->format($projectedMrr * 12, $currency)}"); + } + + $this->newLine(); + } + + private function format(float $amount, string $currency): string + { + $symbol = match (strtolower($currency)) { + 'eur' => '€', + 'gbp' => '£', + 'usd' => '$', + 'jpy' => '¥', + 'brl' => 'R$', + default => strtoupper($currency).' ', + }; + + return $symbol.number_format($amount, 2); + } +} diff --git a/tests/Feature/StripeSubscriptionStatsCommandTest.php b/tests/Feature/StripeSubscriptionStatsCommandTest.php new file mode 100644 index 00000000..740980f9 --- /dev/null +++ b/tests/Feature/StripeSubscriptionStatsCommandTest.php @@ -0,0 +1,121 @@ + 'subscription', + 'currency' => $currency, + 'items' => [ + 'object' => 'list', + 'data' => [ + [ + 'object' => 'subscription_item', + 'quantity' => $quantity, + 'price' => [ + 'object' => 'price', + 'unit_amount' => $unitAmount, + 'recurring' => [ + 'interval' => $interval, + 'interval_count' => $intervalCount, + ], + ], + ], + ], + ], + ]); +} + +function makeSubscriptionCollection(array $subscriptions): Collection +{ + return Collection::constructFrom([ + 'object' => 'list', + 'has_more' => false, + 'data' => array_map(fn (Subscription $s) => $s->toArray(), $subscriptions), + ]); +} + +/** + * @param array> $byStatus + */ +function bindMockStripeClientForStats(array $byStatus): void +{ + $subscriptionService = Mockery::mock(SubscriptionService::class); + + $subscriptionService->shouldReceive('all') + ->andReturnUsing(function (array $params) use ($byStatus): Collection { + $status = $params['status'] ?? 'active'; + + return makeSubscriptionCollection($byStatus[$status] ?? []); + }); + + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->subscriptions = $subscriptionService; + + app()->bind(StripeClient::class, fn () => $stripeClient); +} + +test('warns when there are no subscriptions', function () { + bindMockStripeClientForStats(['active' => [], 'trialing' => []]); + + $this->artisan('stripe:subscription-stats') + ->expectsOutputToContain('No active or trialing subscriptions found') + ->assertSuccessful(); +}); + +test('reports counts and current/projected MRR and ARR', function () { + bindMockStripeClientForStats([ + 'active' => [ + makeStripeSubscription('eur', 399, 'month'), + makeStripeSubscription('eur', 2388, 'year'), + ], + 'trialing' => [ + makeStripeSubscription('eur', 399, 'month'), + ], + ]); + + // current MRR = 3.99 + (23.88/12 = 1.99) = 5.98 ; ARR = 71.76 + // projected MRR = 5.98 + 3.99 = 9.97 ; ARR = 119.64 + $this->artisan('stripe:subscription-stats') + ->expectsOutputToContain('Active subs:') + ->expectsOutputToContain('Trialing subs:') + ->expectsOutputToContain('€5.98') + ->expectsOutputToContain('€71.76') + ->expectsOutputToContain('€9.97') + ->expectsOutputToContain('€119.64') + ->assertSuccessful(); +}); + +test('groups stats per currency', function () { + bindMockStripeClientForStats([ + 'active' => [ + makeStripeSubscription('eur', 1000, 'month'), + makeStripeSubscription('brl', 5000, 'month'), + ], + 'trialing' => [], + ]); + + $this->artisan('stripe:subscription-stats') + ->expectsOutputToContain('EUR') + ->expectsOutputToContain('€10.00') + ->expectsOutputToContain('BRL') + ->expectsOutputToContain('R$50.00') + ->assertSuccessful(); +}); + +test('accounts for quantity in MRR', function () { + bindMockStripeClientForStats([ + 'active' => [ + makeStripeSubscription('eur', 1000, 'month', quantity: 3), + ], + 'trialing' => [], + ]); + + $this->artisan('stripe:subscription-stats') + ->expectsOutputToContain('€30.00') + ->assertSuccessful(); +});