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.
168 lines
5.4 KiB
PHP
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
|
|
};
|
|
}
|
|
}
|