Files
zemailnator/app/Filament/Widgets/ChurnAnalysis.php
idevakk e60973c391 fix(widgets): add multi-database compatibility to all dashboard widgets
Replace SQLite-specific functions with database-agnostic expressions to support
  MySQL, SQLite, PostgreSQL, and SQL Server across all Filament dashboard widgets.

  - Fix strftime() date formatting in SubscriptionMetrics, RevenueMetrics, and TrialPerformance
  - Fix CAST AS REAL syntax in ChurnAnalysis widget
  - Add getDateFormatExpression() method for date function compatibility
  - Add getCastExpression() method for CAST syntax compatibility
  - Support MySQL/MariaDB, SQLite, PostgreSQL, and SQL Server drivers
  - Maintain identical functionality across all database types

  Fixes multiple SQLSTATE[42000] syntax errors when using MySQL/MariaDB databases.
2025-12-02 07:09:30 -08:00

168 lines
5.4 KiB
PHP

<?php
namespace App\Filament\Widgets;
use App\Models\Subscription;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
class ChurnAnalysis extends ChartWidget
{
protected static ?int $sort = 3;
protected int|string|array $columnSpan = 'full';
public function getHeading(): string
{
return 'Churn Analysis';
}
protected function getData(): array
{
$monthlyChurn = $this->getMonthlyChurnRate();
$churnByProvider = $this->getChurnByProvider();
$churnByPlan = $this->getChurnByPlan();
return [
'datasets' => [
[
'label' => 'Monthly Churn Rate (%)',
'data' => array_values($monthlyChurn),
'borderColor' => 'rgba(239, 68, 68, 1)',
'backgroundColor' => 'rgba(239, 68, 68, 0.1)',
'fill' => true,
'tension' => 0.4,
],
],
'labels' => array_keys($monthlyChurn),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): array
{
return [
'responsive' => true,
'plugins' => [
'legend' => [
'position' => 'top',
],
'tooltip' => [
'callbacks' => [
'label' => 'function(context) {
let label = context.dataset.label || "";
if (label) {
label += ": ";
}
if (context.parsed.y !== null) {
label += context.parsed.y.toFixed(1) + "%";
}
return label;
}',
],
],
],
'scales' => [
'y' => [
'beginAtZero' => true,
'max' => 20,
'ticks' => [
'callback' => 'function(value) {
return value + "%";
}',
],
],
],
];
}
private function getMonthlyChurnRate(): array
{
$churnData = [];
for ($i = 5; $i >= 0; $i--) {
$month = now()->subMonths($i);
$monthStart = $month->copy()->startOfMonth();
$monthEnd = $month->copy()->endOfMonth();
// Active subscriptions at the beginning of the month
$activeAtStart = Subscription::query()
->where('status', 'active')
->where('created_at', '<', $monthStart)
->count();
// Subscriptions cancelled during the month
$cancelledDuringMonth = Subscription::query()
->where('status', 'cancelled')
->whereBetween('cancelled_at', [$monthStart, $monthEnd])
->count();
// Calculate churn rate
$churnRate = $activeAtStart > 0 ? ($cancelledDuringMonth / $activeAtStart) * 100 : 0;
$churnData[$month->format('M Y')] = round($churnRate, 2);
}
return $churnData;
}
private function getChurnByProvider(): array
{
$castExpression = $this->getCastExpression();
return Subscription::query()
->select(
'provider',
DB::raw('COUNT(CASE WHEN status = "cancelled" THEN 1 END) as cancelled'),
DB::raw('COUNT(*) as total'),
DB::raw("({$castExpression} * 100.0 / COUNT(*)) as churn_rate")
)
->groupBy('provider')
->orderBy('churn_rate', 'desc')
->get()
->mapWithKeys(function ($item) {
return [$item->provider => round($item->churn_rate, 2)];
})
->toArray();
}
private function getChurnByPlan(): array
{
$castExpression = $this->getCastExpression();
return Subscription::query()
->select(
'plans.name as plan_name',
DB::raw('COUNT(CASE WHEN subscriptions.status = "cancelled" THEN 1 END) as cancelled'),
DB::raw('COUNT(*) as total'),
DB::raw("({$castExpression} * 100.0 / COUNT(*)) as churn_rate")
)
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->groupBy('plans.id', 'plans.name')
->orderBy('churn_rate', 'desc')
->limit(10)
->get()
->mapWithKeys(function ($item) {
return [$item->plan_name => round($item->churn_rate, 2)];
})
->toArray();
}
private function getCastExpression(): string
{
$connection = DB::connection()->getDriverName();
return match ($connection) {
'sqlite' => 'CAST(COUNT(CASE WHEN status = "cancelled" THEN 1 END) AS REAL)',
'mysql', 'mariadb' => 'CAST(COUNT(CASE WHEN status = "cancelled" THEN 1 END) AS DECIMAL(10,2))',
'pgsql' => 'CAST(COUNT(CASE WHEN status = "cancelled" THEN 1 END) AS NUMERIC)',
'sqlsrv' => 'CAST(COUNT(CASE WHEN status = "cancelled" THEN 1 END) AS FLOAT)',
default => 'CAST(COUNT(CASE WHEN status = "cancelled" THEN 1 END) AS DECIMAL(10,2))', // fallback to MySQL format
};
}
}