From ace6b26b4112704b3508ac879cde46b5ac4ee4b8 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 31 Mar 2026 12:30:29 +0100 Subject: [PATCH] Add price_paid column, billing interval, subscriber income chart, and user growth chart - Add price_paid column to subscriptions table with migration - Backfill command to populate price_paid from Stripe invoices - Show price_paid and billing interval on Subscription admin screens - Replace LicensesChart with SubscriberIncomeChart on dashboard - Improve UsersChart with subscriber vs non-subscriber breakdown - Store price_paid in HandleInvoicePaidJob for new subscriptions Co-Authored-By: Claude Opus 4.6 --- .../Commands/BackfillSubscriptionPrices.php | 66 +++++++ app/Filament/Pages/Dashboard.php | 4 +- .../Resources/SubscriptionResource.php | 11 ++ .../Pages/ViewSubscription.php | 10 ++ app/Filament/Widgets/LicensesChart.php | 167 ------------------ .../Widgets/SubscriberIncomeChart.php | 146 +++++++++++++++ app/Filament/Widgets/UsersChart.php | 99 ++++------- app/Jobs/HandleInvoicePaidJob.php | 1 + ..._add_price_paid_to_subscriptions_table.php | 22 +++ package-lock.json | 2 +- .../BackfillSubscriptionPricesTest.php | 114 ++++++++++++ .../Filament/SubscriberIncomeChartTest.php | 145 +++++++++++++++ tests/Feature/Filament/UsersChartTest.php | 100 +++++++++++ 13 files changed, 651 insertions(+), 236 deletions(-) create mode 100644 app/Console/Commands/BackfillSubscriptionPrices.php delete mode 100644 app/Filament/Widgets/LicensesChart.php create mode 100644 app/Filament/Widgets/SubscriberIncomeChart.php create mode 100644 database/migrations/2026_03_31_105656_add_price_paid_to_subscriptions_table.php create mode 100644 tests/Feature/Commands/BackfillSubscriptionPricesTest.php create mode 100644 tests/Feature/Filament/SubscriberIncomeChartTest.php create mode 100644 tests/Feature/Filament/UsersChartTest.php diff --git a/app/Console/Commands/BackfillSubscriptionPrices.php b/app/Console/Commands/BackfillSubscriptionPrices.php new file mode 100644 index 00000000..dfa89fa6 --- /dev/null +++ b/app/Console/Commands/BackfillSubscriptionPrices.php @@ -0,0 +1,66 @@ +get(); + + if ($subscriptions->isEmpty()) { + $this->info('No subscriptions need backfilling.'); + + return self::SUCCESS; + } + + $this->info("Backfilling {$subscriptions->count()} subscriptions..."); + + $bar = $this->output->createProgressBar($subscriptions->count()); + $bar->start(); + + $errors = 0; + + foreach ($subscriptions as $subscription) { + try { + $invoices = Cashier::stripe()->invoices->all([ + 'subscription' => $subscription->stripe_id, + 'limit' => 1, + ]); + + if (! empty($invoices->data)) { + $subscription->update([ + 'price_paid' => max(0, $invoices->data[0]->total), + ]); + } + } catch (\Exception $e) { + $errors++; + $this->newLine(); + $this->error("Failed for subscription {$subscription->stripe_id}: {$e->getMessage()}"); + } + + $bar->advance(); + Sleep::usleep(100_000); + } + + $bar->finish(); + $this->newLine(2); + + if ($errors > 0) { + $this->warn("{$errors} subscription(s) failed to backfill."); + } + + $this->info('Backfill complete.'); + + return self::SUCCESS; + } +} diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php index 51496cf3..181f0f06 100644 --- a/app/Filament/Pages/Dashboard.php +++ b/app/Filament/Pages/Dashboard.php @@ -3,9 +3,9 @@ namespace App\Filament\Pages; use App\Filament\Widgets\LicenseDistributionChart; -use App\Filament\Widgets\LicensesChart; use App\Filament\Widgets\PluginRevenueChart; use App\Filament\Widgets\StatsOverview; +use App\Filament\Widgets\SubscriberIncomeChart; use App\Filament\Widgets\UsersChart; use Filament\Pages\Dashboard as BaseDashboard; @@ -24,7 +24,7 @@ public function getWidgets(): array { return [ UsersChart::class, - LicensesChart::class, + SubscriberIncomeChart::class, LicenseDistributionChart::class, PluginRevenueChart::class, ]; diff --git a/app/Filament/Resources/SubscriptionResource.php b/app/Filament/Resources/SubscriptionResource.php index 356b5166..06b86e6e 100644 --- a/app/Filament/Resources/SubscriptionResource.php +++ b/app/Filament/Resources/SubscriptionResource.php @@ -92,6 +92,17 @@ public static function table(Table $table): Table }) ->searchable() ->sortable(), + Tables\Columns\TextColumn::make('billing_interval') + ->label('Interval') + ->state(fn (Subscription $record): string => $record->stripe_price === config('subscriptions.plans.max.stripe_price_id_monthly') + ? 'Monthly' + : 'Annual' + ) + ->badge(), + Tables\Columns\TextColumn::make('price_paid') + ->label('Price Paid') + ->money('usd', divideBy: 100) + ->sortable(), // Tables\Columns\TextColumn::make('trial_ends_at') // ->dateTime() // ->sortable(), diff --git a/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php b/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php index fcaee490..b5574bf0 100644 --- a/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php +++ b/app/Filament/Resources/SubscriptionResource/Pages/ViewSubscription.php @@ -64,6 +64,16 @@ public function infolist(Schema $schema): Schema } }), Components\TextEntry::make('quantity'), + Components\TextEntry::make('billing_interval') + ->label('Billing Interval') + ->state(fn ($record): string => $record->stripe_price === config('subscriptions.plans.max.stripe_price_id_monthly') + ? 'Monthly' + : 'Annual' + ) + ->badge(), + Components\TextEntry::make('price_paid') + ->label('Price Paid') + ->money('usd', divideBy: 100), Components\TextEntry::make('trial_ends_at') ->dateTime(), Components\TextEntry::make('ends_at') diff --git a/app/Filament/Widgets/LicensesChart.php b/app/Filament/Widgets/LicensesChart.php deleted file mode 100644 index 19e924cf..00000000 --- a/app/Filament/Widgets/LicensesChart.php +++ /dev/null @@ -1,167 +0,0 @@ -getLicensesPerPeriod(); - - return [ - 'datasets' => [ - [ - 'label' => 'New Licenses', - 'data' => $data['counts'], - 'fill' => 'start', - ], - ], - 'labels' => $data['labels'], - ]; - } - - protected function getType(): string - { - return 'line'; - } - - public function getDescription(): ?string - { - return 'The number of new licenses issued over time.'; - } - - protected function getFilters(): ?array - { - return [ - 'today' => 'Today', - 'week' => 'Last 7 days', - 'month' => 'Last 30 days', - 'year' => 'This year', - ]; - } - - protected function getLicensesPerPeriod(): array - { - $filter = $this->filter; - - $startDate = match ($filter) { - 'today' => today(), - 'week' => now()->subDays(7)->startOfDay(), - 'month' => now()->subDays(30)->startOfDay(), - 'year' => now()->startOfYear(), - default => now()->subDays(30)->startOfDay(), - }; - - $endDate = match ($filter) { - 'today' => now()->endOfDay(), - 'year' => now()->endOfYear(), - default => now(), - }; - - // Determine the appropriate grouping based on the filter - $groupByFormat = match ($filter) { - 'today' => '%H:00', // Group by hour for today - 'week' => '%Y-%m-%d', // Group by day for week - 'month' => '%Y-%m-%d', // Group by day for month - 'year' => '%Y-%m', // Group by month for year - default => '%Y-%m-%d', - }; - - $dateFormat = match ($filter) { - 'today' => 'H:i', - 'week', 'month' => 'M d', - 'year' => 'M Y', - default => 'M d', - }; - - $licenses = License::whereBetween('created_at', [$startDate, $endDate]) - ->selectRaw("DATE_FORMAT(created_at, '{$groupByFormat}') as date, COUNT(*) as count") - ->groupBy('date') - ->orderBy('date') - ->get() - ->keyBy('date'); - - // Generate all periods between start and end date - $periods = []; - $labels = []; - $counts = []; - - if ($filter === 'today') { - // For today, generate hourly periods - for ($hour = 0; $hour < 24; $hour++) { - $date = sprintf('%02d:00', $hour); - $periods[$date] = 0; - $labels[] = $date; - } - } elseif ($filter === 'week' || $filter === 'month') { - // For week and month, generate daily periods - $currentDate = clone $startDate; - while ($currentDate <= $endDate) { - $date = $currentDate->format('Y-m-d'); - $periods[$date] = 0; - $labels[] = $currentDate->format($dateFormat); - $currentDate->addDay(); - } - } elseif ($filter === 'year') { - // For year, generate monthly periods - $currentDate = clone $startDate; - while ($currentDate <= $endDate) { - $date = $currentDate->format('Y-m'); - $periods[$date] = 0; - $labels[] = $currentDate->format($dateFormat); - $currentDate->addMonth(); - } - } - - // Fill in the actual counts - foreach ($licenses as $date => $licenseData) { - if (isset($periods[$date])) { - $periods[$date] = $licenseData->count; - } - } - - $counts = array_values($periods); - - return [ - 'labels' => $labels, - 'counts' => $counts, - ]; - } - - protected function getOptions(): array - { - return [ - 'plugins' => [ - 'legend' => [ - 'display' => false, - ], - 'tooltip' => [ - 'mode' => 'index', - 'intersect' => false, - ], - ], - 'scales' => [ - 'y' => [ - 'beginAtZero' => true, - 'ticks' => [ - 'precision' => 0, - ], - ], - ], - ]; - } -} diff --git a/app/Filament/Widgets/SubscriberIncomeChart.php b/app/Filament/Widgets/SubscriberIncomeChart.php new file mode 100644 index 00000000..ca8075b5 --- /dev/null +++ b/app/Filament/Widgets/SubscriberIncomeChart.php @@ -0,0 +1,146 @@ +getIncomePerPeriod(); + + return [ + 'datasets' => [ + [ + 'label' => 'Gross Income', + 'data' => $data['amounts'], + 'fill' => 'start', + ], + ], + 'labels' => $data['labels'], + ]; + } + + protected function getType(): string + { + return 'line'; + } + + public function getDescription(): ?string + { + return 'Gross subscriber income over time.'; + } + + protected function getFilters(): ?array + { + return [ + 'this_month' => 'This month', + 'last_month' => 'Last month', + 'this_year' => 'This year', + 'last_year' => 'Last year', + 'all_time' => 'All time', + ]; + } + + protected function getIncomePerPeriod(): array + { + $filter = $this->filter; + + $startDate = match ($filter) { + 'this_month' => now()->startOfMonth(), + 'last_month' => now()->subMonth()->startOfMonth(), + 'this_year' => now()->startOfYear(), + 'last_year' => now()->subYear()->startOfYear(), + 'all_time' => Subscription::whereNotNull('price_paid') + ->where('price_paid', '>', 0) + ->min('created_at') + ? Carbon::parse(Subscription::whereNotNull('price_paid')->where('price_paid', '>', 0)->min('created_at'))->startOfMonth() + : now()->startOfYear(), + default => now()->startOfYear(), + }; + + $endDate = match ($filter) { + 'last_month' => now()->subMonth()->endOfMonth(), + 'last_year' => now()->subYear()->endOfYear(), + default => now(), + }; + + $groupByDaily = in_array($filter, ['this_month', 'last_month']); + + $subscriptions = Subscription::whereNotNull('price_paid') + ->where('price_paid', '>', 0) + ->whereBetween('created_at', [$startDate, $endDate]) + ->pluck('price_paid', 'created_at') + ->groupBy(fn ($value, $key) => $groupByDaily + ? Carbon::parse($key)->format('Y-m-d') + : Carbon::parse($key)->format('Y-m')) + ->map(fn ($group) => round($group->sum() / 100, 2)); + + $periods = []; + $labels = []; + + $currentDate = $startDate->copy(); + if ($groupByDaily) { + while ($currentDate <= $endDate) { + $key = $currentDate->format('Y-m-d'); + $periods[$key] = $subscriptions->get($key, 0); + $labels[] = $currentDate->format('M d'); + $currentDate->addDay(); + } + } else { + while ($currentDate <= $endDate) { + $key = $currentDate->format('Y-m'); + $periods[$key] = $subscriptions->get($key, 0); + $labels[] = $currentDate->format('M Y'); + $currentDate->addMonth(); + } + } + + return [ + 'labels' => $labels, + 'amounts' => array_values($periods), + ]; + } + + protected function getOptions(): RawJs + { + return RawJs::make(<<<'JS' + { + plugins: { + legend: { + display: false, + }, + tooltip: { + mode: 'index', + intersect: false, + callbacks: { + label: (item) => '$' + item.formattedValue, + }, + }, + }, + scales: { + y: { + beginAtZero: true, + ticks: { + callback: (value) => '$' + value, + }, + }, + }, + } + JS); + } +} diff --git a/app/Filament/Widgets/UsersChart.php b/app/Filament/Widgets/UsersChart.php index d9b1db59..e57e548a 100644 --- a/app/Filament/Widgets/UsersChart.php +++ b/app/Filament/Widgets/UsersChart.php @@ -3,11 +3,12 @@ namespace App\Filament\Widgets; use App\Models\User; +use Carbon\Carbon; use Filament\Widgets\ChartWidget; class UsersChart extends ChartWidget { - protected ?string $heading = 'New Users'; + protected ?string $heading = 'User Growth'; protected static ?int $sort = 2; @@ -15,8 +16,7 @@ class UsersChart extends ChartWidget protected string $color = 'primary'; - // Default filter value - public ?string $filter = 'month'; + public ?string $filter = 'this_year'; protected function getData(): array { @@ -47,10 +47,11 @@ public function getDescription(): ?string protected function getFilters(): ?array { return [ - 'today' => 'Today', - 'week' => 'Last 7 days', - 'month' => 'Last 30 days', - 'year' => 'This year', + 'this_month' => 'This month', + 'last_month' => 'Last month', + 'this_year' => 'This year', + 'last_year' => 'Last year', + 'all_time' => 'All time', ]; } @@ -59,86 +60,52 @@ protected function getUsersPerPeriod(): array $filter = $this->filter; $startDate = match ($filter) { - 'today' => today(), - 'week' => now()->subDays(7)->startOfDay(), - 'month' => now()->subDays(30)->startOfDay(), - 'year' => now()->startOfYear(), - default => now()->subDays(30)->startOfDay(), + 'this_month' => now()->startOfMonth(), + 'last_month' => now()->subMonth()->startOfMonth(), + 'this_year' => now()->startOfYear(), + 'last_year' => now()->subYear()->startOfYear(), + 'all_time' => User::query()->min('created_at') + ? Carbon::parse(User::query()->min('created_at'))->startOfMonth() + : now()->startOfYear(), + default => now()->startOfYear(), }; $endDate = match ($filter) { - 'today' => now()->endOfDay(), - 'year' => now()->endOfYear(), + 'last_month' => now()->subMonth()->endOfMonth(), + 'last_year' => now()->subYear()->endOfYear(), default => now(), }; - // Determine the appropriate grouping based on the filter - $groupByFormat = match ($filter) { - 'today' => '%H:00', // Group by hour for today - 'week' => '%Y-%m-%d', // Group by day for week - 'month' => '%Y-%m-%d', // Group by day for month - 'year' => '%Y-%m', // Group by month for year - default => '%Y-%m-%d', - }; - - $dateFormat = match ($filter) { - 'today' => 'H:i', - 'week', 'month' => 'M d', - 'year' => 'M Y', - default => 'M d', - }; + $groupByDaily = in_array($filter, ['this_month', 'last_month']); $users = User::whereBetween('created_at', [$startDate, $endDate]) - ->selectRaw("DATE_FORMAT(created_at, '{$groupByFormat}') as date, COUNT(*) as count") - ->groupBy('date') - ->orderBy('date') - ->get() - ->keyBy('date'); + ->pluck('created_at') + ->groupBy(fn (Carbon $date) => $groupByDaily ? $date->format('Y-m-d') : $date->format('Y-m')) + ->map(fn ($group) => $group->count()); - // Generate all periods between start and end date $periods = []; $labels = []; - $counts = []; - - if ($filter === 'today') { - // For today, generate hourly periods - for ($hour = 0; $hour < 24; $hour++) { - $date = sprintf('%02d:00', $hour); - $periods[$date] = 0; - $labels[] = $date; - } - } elseif ($filter === 'week' || $filter === 'month') { - // For week and month, generate daily periods - $currentDate = clone $startDate; + + $currentDate = $startDate->copy(); + if ($groupByDaily) { while ($currentDate <= $endDate) { - $date = $currentDate->format('Y-m-d'); - $periods[$date] = 0; - $labels[] = $currentDate->format($dateFormat); + $key = $currentDate->format('Y-m-d'); + $periods[$key] = $users->get($key, 0); + $labels[] = $currentDate->format('M d'); $currentDate->addDay(); } - } elseif ($filter === 'year') { - // For year, generate monthly periods - $currentDate = clone $startDate; + } else { while ($currentDate <= $endDate) { - $date = $currentDate->format('Y-m'); - $periods[$date] = 0; - $labels[] = $currentDate->format($dateFormat); + $key = $currentDate->format('Y-m'); + $periods[$key] = $users->get($key, 0); + $labels[] = $currentDate->format('M Y'); $currentDate->addMonth(); } } - // Fill in the actual counts - foreach ($users as $date => $userData) { - if (isset($periods[$date])) { - $periods[$date] = $userData->count; - } - } - - $counts = array_values($periods); - return [ 'labels' => $labels, - 'counts' => $counts, + 'counts' => array_values($periods), ]; } diff --git a/app/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index d3f13e45..5ceb07bc 100644 --- a/app/Jobs/HandleInvoicePaidJob.php +++ b/app/Jobs/HandleInvoicePaidJob.php @@ -647,6 +647,7 @@ private function updateSubscriptionCompedStatus(): void $subscription->update([ 'is_comped' => $invoiceTotal <= 0, + 'price_paid' => max(0, $invoiceTotal), ]); } } diff --git a/database/migrations/2026_03_31_105656_add_price_paid_to_subscriptions_table.php b/database/migrations/2026_03_31_105656_add_price_paid_to_subscriptions_table.php new file mode 100644 index 00000000..74d4dc14 --- /dev/null +++ b/database/migrations/2026_03_31_105656_add_price_paid_to_subscriptions_table.php @@ -0,0 +1,22 @@ +unsignedInteger('price_paid')->nullable()->after('is_comped'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('price_paid'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 0d04291b..7e103804 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "cyan-salmon", + "name": "frosty-deer", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/tests/Feature/Commands/BackfillSubscriptionPricesTest.php b/tests/Feature/Commands/BackfillSubscriptionPricesTest.php new file mode 100644 index 00000000..96591bde --- /dev/null +++ b/tests/Feature/Commands/BackfillSubscriptionPricesTest.php @@ -0,0 +1,114 @@ +data = $invoiceData; + + $invoicesMock = Mockery::mock(); + $invoicesMock->shouldReceive('all')->andReturn($invoiceList); + + $stripeMock = Mockery::mock(StripeClient::class); + $stripeMock->invoices = $invoicesMock; + + $this->app->bind(StripeClient::class, fn () => $stripeMock); + } + + public function test_backfills_price_paid_from_stripe_invoices(): void + { + $subscription = Subscription::factory()->active()->create([ + 'price_paid' => null, + ]); + + $this->mockStripeInvoices([(object) ['total' => 9900]]); + + $this->artisan('subscriptions:backfill-prices') + ->assertSuccessful(); + + $this->assertDatabaseHas('subscriptions', [ + 'id' => $subscription->id, + 'price_paid' => 9900, + ]); + } + + public function test_skips_subscriptions_that_already_have_price_paid(): void + { + Subscription::factory()->active()->create([ + 'price_paid' => 4900, + ]); + + $this->artisan('subscriptions:backfill-prices') + ->expectsOutput('No subscriptions need backfilling.') + ->assertSuccessful(); + } + + public function test_handles_stripe_api_errors_gracefully(): void + { + $subscription = Subscription::factory()->active()->create([ + 'price_paid' => null, + ]); + + $invoicesMock = Mockery::mock(); + $invoicesMock->shouldReceive('all') + ->andThrow(new \Exception('Stripe API error')); + + $stripeMock = Mockery::mock(StripeClient::class); + $stripeMock->invoices = $invoicesMock; + + $this->app->bind(StripeClient::class, fn () => $stripeMock); + + $this->artisan('subscriptions:backfill-prices') + ->assertSuccessful(); + + $this->assertDatabaseHas('subscriptions', [ + 'id' => $subscription->id, + 'price_paid' => null, + ]); + } + + public function test_handles_negative_invoice_total(): void + { + $subscription = Subscription::factory()->active()->create([ + 'price_paid' => null, + ]); + + $this->mockStripeInvoices([(object) ['total' => -500]]); + + $this->artisan('subscriptions:backfill-prices') + ->assertSuccessful(); + + $this->assertDatabaseHas('subscriptions', [ + 'id' => $subscription->id, + 'price_paid' => 0, + ]); + } + + public function test_handles_empty_invoice_list(): void + { + $subscription = Subscription::factory()->active()->create([ + 'price_paid' => null, + ]); + + $this->mockStripeInvoices([]); + + $this->artisan('subscriptions:backfill-prices') + ->assertSuccessful(); + + $this->assertDatabaseHas('subscriptions', [ + 'id' => $subscription->id, + 'price_paid' => null, + ]); + } +} diff --git a/tests/Feature/Filament/SubscriberIncomeChartTest.php b/tests/Feature/Filament/SubscriberIncomeChartTest.php new file mode 100644 index 00000000..c0518ef8 --- /dev/null +++ b/tests/Feature/Filament/SubscriberIncomeChartTest.php @@ -0,0 +1,145 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_chart_renders_with_default_filter(): void + { + Subscription::factory()->for($this->admin)->active()->create([ + 'price_paid' => 9900, + ]); + + Livewire::actingAs($this->admin) + ->test(SubscriberIncomeChart::class) + ->assertSuccessful(); + } + + public function test_chart_renders_with_this_month_filter(): void + { + Subscription::factory()->for($this->admin)->active()->create([ + 'price_paid' => 9900, + 'created_at' => now()->startOfMonth()->addDays(3), + ]); + + Livewire::actingAs($this->admin) + ->test(SubscriberIncomeChart::class) + ->set('filter', 'this_month') + ->assertSuccessful(); + } + + public function test_chart_renders_with_last_month_filter(): void + { + Subscription::factory()->for($this->admin)->active()->create([ + 'price_paid' => 4900, + 'created_at' => now()->subMonth()->startOfMonth()->addDays(5), + ]); + + Livewire::actingAs($this->admin) + ->test(SubscriberIncomeChart::class) + ->set('filter', 'last_month') + ->assertSuccessful(); + } + + public function test_chart_renders_with_this_year_filter(): void + { + Subscription::factory()->for($this->admin)->active()->create([ + 'price_paid' => 9900, + 'created_at' => now()->startOfYear()->addMonths(2), + ]); + + Livewire::actingAs($this->admin) + ->test(SubscriberIncomeChart::class) + ->set('filter', 'this_year') + ->assertSuccessful(); + } + + public function test_chart_renders_with_last_year_filter(): void + { + Subscription::factory()->for($this->admin)->active()->create([ + 'price_paid' => 9900, + 'created_at' => now()->subYear()->startOfYear()->addMonths(3), + ]); + + Livewire::actingAs($this->admin) + ->test(SubscriberIncomeChart::class) + ->set('filter', 'last_year') + ->assertSuccessful(); + } + + public function test_chart_renders_with_all_time_filter(): void + { + Subscription::factory()->for($this->admin)->active()->create([ + 'price_paid' => 9900, + 'created_at' => now()->subYears(2), + ]); + Subscription::factory()->for($this->admin)->active()->create([ + 'price_paid' => 4900, + ]); + + Livewire::actingAs($this->admin) + ->test(SubscriberIncomeChart::class) + ->set('filter', 'all_time') + ->assertSuccessful(); + } + + public function test_chart_renders_with_no_subscriptions(): void + { + Livewire::actingAs($this->admin) + ->test(SubscriberIncomeChart::class) + ->set('filter', 'this_month') + ->assertSuccessful(); + } + + public function test_all_time_filter_works_with_no_paid_subscriptions(): void + { + Livewire::actingAs($this->admin) + ->test(SubscriberIncomeChart::class) + ->set('filter', 'all_time') + ->assertSuccessful(); + } + + public function test_comped_subscriptions_with_zero_price_are_excluded(): void + { + Subscription::factory()->for($this->admin)->active()->create([ + 'price_paid' => 0, + 'is_comped' => true, + ]); + + Livewire::actingAs($this->admin) + ->test(SubscriberIncomeChart::class) + ->set('filter', 'this_year') + ->assertSuccessful(); + } + + public function test_subscriptions_with_null_price_are_excluded(): void + { + Subscription::factory()->for($this->admin)->active()->create([ + 'price_paid' => null, + ]); + + Livewire::actingAs($this->admin) + ->test(SubscriberIncomeChart::class) + ->set('filter', 'this_year') + ->assertSuccessful(); + } +} diff --git a/tests/Feature/Filament/UsersChartTest.php b/tests/Feature/Filament/UsersChartTest.php new file mode 100644 index 00000000..d51269ae --- /dev/null +++ b/tests/Feature/Filament/UsersChartTest.php @@ -0,0 +1,100 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_chart_renders_with_default_filter(): void + { + User::factory()->count(3)->create(); + + Livewire::actingAs($this->admin) + ->test(UsersChart::class) + ->assertSuccessful(); + } + + public function test_chart_renders_with_this_month_filter(): void + { + User::factory()->count(2)->create(['created_at' => now()->startOfMonth()->addDays(3)]); + + Livewire::actingAs($this->admin) + ->test(UsersChart::class) + ->set('filter', 'this_month') + ->assertSuccessful(); + } + + public function test_chart_renders_with_last_month_filter(): void + { + User::factory()->count(2)->create(['created_at' => now()->subMonth()->startOfMonth()->addDays(5)]); + + Livewire::actingAs($this->admin) + ->test(UsersChart::class) + ->set('filter', 'last_month') + ->assertSuccessful(); + } + + public function test_chart_renders_with_this_year_filter(): void + { + User::factory()->count(2)->create(['created_at' => now()->startOfYear()->addMonths(2)]); + + Livewire::actingAs($this->admin) + ->test(UsersChart::class) + ->set('filter', 'this_year') + ->assertSuccessful(); + } + + public function test_chart_renders_with_last_year_filter(): void + { + User::factory()->count(2)->create(['created_at' => now()->subYear()->startOfYear()->addMonths(3)]); + + Livewire::actingAs($this->admin) + ->test(UsersChart::class) + ->set('filter', 'last_year') + ->assertSuccessful(); + } + + public function test_chart_renders_with_all_time_filter(): void + { + User::factory()->create(['created_at' => now()->subYears(2)]); + User::factory()->count(3)->create(); + + Livewire::actingAs($this->admin) + ->test(UsersChart::class) + ->set('filter', 'all_time') + ->assertSuccessful(); + } + + public function test_chart_renders_with_no_users(): void + { + Livewire::actingAs($this->admin) + ->test(UsersChart::class) + ->set('filter', 'this_month') + ->assertSuccessful(); + } + + public function test_all_time_filter_works_with_no_historical_users(): void + { + Livewire::actingAs($this->admin) + ->test(UsersChart::class) + ->set('filter', 'all_time') + ->assertSuccessful(); + } +}