feat: add Stripe subscription stats command (#457)
## What Adds a `stripe:subscription-stats` Artisan command that prints subscription stats to the CLI. Shows, per currency: - **Active subs** count + their MRR - **Trialing subs** count + their MRR - **Current MRR & ARR** (active subscriptions only) - **Projected MRR & ARR** (if trialing subs convert to active) ## How - Queries Stripe directly via `Cashier::stripe()`, auto-paginating `active` and `trialing` subscriptions. - MRR per subscription sums all items, normalizing each price to a monthly value (handles day/week/month/year intervals, `interval_count`, and quantity). - Groups results per currency (EUR, BRL, …) since aggregating across currencies would be incorrect. ```bash php artisan stripe:subscription-stats ``` ## Tests 4 Pest feature tests covering empty state, MRR/ARR math, per-currency grouping, and quantity handling. Mocks the Stripe client.
This commit is contained in:
parent
144d919c0b
commit
670a0a65c7
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
use Stripe\Subscription;
|
||||
|
||||
class StripeSubscriptionStatsCommand extends Command
|
||||
{
|
||||
protected $signature = 'stripe:subscription-stats';
|
||||
|
||||
protected $description = 'Show Stripe subscription stats: active/trialing counts and current/projected MRR & ARR';
|
||||
|
||||
/**
|
||||
* @var array<string, array{count: int, mrr: float}>
|
||||
*/
|
||||
private array $active = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array{count: int, mrr: float}>
|
||||
*/
|
||||
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<string, array{count: int, mrr: float}> $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('<options=bold>'.strtoupper($currency).'</>');
|
||||
$this->line(" Active subs: <fg=green>{$active['count']}</> ({$this->format($active['mrr'], $currency)} MRR)");
|
||||
$this->line(" Trialing subs: <fg=yellow>{$trialing['count']}</> ({$this->format($trialing['mrr'], $currency)} MRR)");
|
||||
$this->newLine();
|
||||
$this->line(" Current MRR: <fg=cyan>{$this->format($currentMrr, $currency)}</>");
|
||||
$this->line(" Current ARR: <fg=cyan>{$this->format($currentMrr * 12, $currency)}</>");
|
||||
$this->line(" Projected MRR: <fg=cyan>{$this->format($projectedMrr, $currency)}</> (if trialing convert)");
|
||||
$this->line(" Projected ARR: <fg=cyan>{$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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
use Stripe\Collection;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
use Stripe\Subscription;
|
||||
|
||||
function makeStripeSubscription(string $currency, int $unitAmount, string $interval, int $quantity = 1, int $intervalCount = 1): Subscription
|
||||
{
|
||||
return Subscription::constructFrom([
|
||||
'object' => '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<string, list<Subscription>> $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();
|
||||
});
|
||||
Loading…
Reference in New Issue