Files
zemailnator/app/Filament/Widgets/CustomerLifetimeValue.php
idevakk 27ac13948c feat: implement comprehensive multi-provider payment processing system
- Add unified payment provider architecture with contract-based design
  - Implement 6 payment providers: Stripe, Lemon Squeezy, Polar, Oxapay, Crypto, Activation Keys
  - Create subscription management with lifecycle handling (create, cancel, pause, resume, update)
  - Add coupon system with usage tracking and trial extensions
  - Build Filament admin resources for payment providers, subscriptions, coupons, and trials
  - Implement payment orchestration service with provider registry and configuration management
  - Add comprehensive payment logging and webhook handling for all providers
  - Create customer analytics dashboard with revenue, churn, and lifetime value metrics
  - Add subscription migration service for provider switching
  - Include extensive test coverage for all payment functionality
2025-11-19 09:37:00 -08:00

226 lines
7.2 KiB
PHP

<?php
namespace App\Filament\Widgets;
use App\Models\Subscription;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
class CustomerLifetimeValue extends ChartWidget
{
protected static ?int $sort = 5;
protected int|string|array $columnSpan = 'full';
public function getHeading(): string
{
return 'Customer Lifetime Value Analysis';
}
protected function getData(): array
{
$ltvByCohort = $this->getLTVByCohort();
$ltvByProvider = $this->getLTVByProvider();
$ltvByPlan = $this->getLTVByPlan();
return [
'datasets' => [
[
'label' => 'Average LTV by Cohort ($)',
'data' => array_values($ltvByCohort),
'borderColor' => 'rgba(34, 197, 94, 1)',
'backgroundColor' => 'rgba(34, 197, 94, 0.1)',
'fill' => true,
'tension' => 0.4,
],
],
'labels' => array_keys($ltvByCohort),
];
}
protected function getType(): string
{
return 'bar';
}
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(2);
}
return label;
}',
],
],
],
'scales' => [
'y' => [
'beginAtZero' => true,
'ticks' => [
'callback' => 'function(value) {
return "$" + value;
}',
],
],
],
];
}
private function getLTVByCohort(): array
{
$cohorts = [];
// Get cohorts by signup month (last 6 months)
for ($i = 5; $i >= 0; $i--) {
$month = now()->subMonths($i);
$monthStart = $month->copy()->startOfMonth();
$monthEnd = $month->copy()->endOfMonth();
// Users who signed up in this month
$cohortUsers = Subscription::query()
->select('user_id')
->whereBetween('created_at', [$monthStart, $monthEnd])
->distinct()
->pluck('user_id');
if ($cohortUsers->isEmpty()) {
$cohorts[$month->format('M Y')] = 0;
continue;
}
// Calculate LTV for this cohort
$totalRevenue = Subscription::query()
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->whereIn('subscriptions.user_id', $cohortUsers)
->where('subscriptions.status', 'active')
->sum('plans.price');
// Estimate LTV based on current MRR and average subscription length
$avgSubscriptionLength = $this->getAverageSubscriptionLength();
$cohortSize = $cohortUsers->count();
$avgMRRPerUser = $cohortSize > 0 ? $totalRevenue / $cohortSize : 0;
$ltv = $avgMRRPerUser * $avgSubscriptionLength;
$cohorts[$month->format('M Y')] = round($ltv, 2);
}
return $cohorts;
}
private function getLTVByProvider(): array
{
return Subscription::query()
->select(
'provider',
DB::raw('AVG(plans.price) as avg_monthly_price'),
DB::raw('COUNT(*) as total_subscriptions')
)
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->where('subscriptions.status', 'active')
->groupBy('provider')
->orderBy('avg_monthly_price', 'desc')
->get()
->mapWithKeys(function ($item) {
$avgLength = $this->getAverageSubscriptionLengthByProvider($item->provider);
$ltv = $item->avg_monthly_price * $avgLength;
return [$item->provider => round($ltv, 2)];
})
->toArray();
}
private function getLTVByPlan(): array
{
return Subscription::query()
->select(
'plans.name as plan_name',
DB::raw('plans.price'),
DB::raw('COUNT(*) as total_subscriptions')
)
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->where('subscriptions.status', 'active')
->groupBy('plans.id', 'plans.name', 'plans.price')
->orderBy('plans.price', 'desc')
->limit(10)
->get()
->mapWithKeys(function ($item) {
$avgLength = $this->getAverageSubscriptionLengthByPlan($item->plan_name);
$ltv = $item->price * $avgLength;
return [$item->plan_name => round($ltv, 2)];
})
->toArray();
}
private function getAverageSubscriptionLength(): float
{
// Average subscription length in months (simplified calculation)
return 12; // Could be calculated based on historical data
}
private function getAverageSubscriptionLengthByProvider(string $provider): float
{
// Provider-specific average subscription length
$providerLengths = [
'stripe' => 14,
'lemon_squeezy' => 12,
'polar' => 10,
'oxapay' => 8,
'crypto' => 6,
'activation_key' => 24,
];
return $providerLengths[$provider] ?? 12;
}
private function getAverageSubscriptionLengthByPlan(string $planName): float
{
// Plan-specific average subscription length (could be based on plan tier)
return 12; // Simplified, could be more sophisticated
}
private function getTopQuartileLTV(): float
{
$ltvs = Subscription::query()
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->where('subscriptions.status', 'active')
->selectRaw('plans.price * ? as ltv', [$this->getAverageSubscriptionLength()])
->pluck('ltv')
->sort()
->values();
$quartileIndex = (int) ($ltvs->count() * 0.75);
return $ltvs->get($quartileIndex, 0);
}
private function getBottomQuartileLTV(): float
{
$ltvs = Subscription::query()
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->where('subscriptions.status', 'active')
->selectRaw('plans.price * ? as ltv', [$this->getAverageSubscriptionLength()])
->pluck('ltv')
->sort()
->values();
$quartileIndex = (int) ($ltvs->count() * 0.25);
return $ltvs->get($quartileIndex, 0);
}
}