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
This commit is contained in:
347
app/Filament/Pages/CustomerAnalytics.php
Normal file
347
app/Filament/Pages/CustomerAnalytics.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\Concerns\InteractsWithActions;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Grid;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use UnitEnum;
|
||||
|
||||
class CustomerAnalytics extends Page implements HasForms, HasTable
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithForms;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|null|\BackedEnum $navigationIcon = 'heroicon-o-chart-bar';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Analytics';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected string $view = 'filament.pages.customer-analytics';
|
||||
|
||||
public $startDate;
|
||||
|
||||
public $endDate;
|
||||
|
||||
public $selectedProvider = 'all';
|
||||
|
||||
public $selectedPlan = 'all';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->startDate = now()->subDays(30)->format('Y-m-d');
|
||||
$this->endDate = now()->format('Y-m-d');
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'Customer Analytics';
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('export_report')
|
||||
->label('Export Report')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('success')
|
||||
->action(fn () => $this->exportReport()),
|
||||
|
||||
Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->action(fn () => $this->resetTable()),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
\App\Filament\Widgets\CustomerAnalyticsOverview::class,
|
||||
\App\Filament\Widgets\SubscriptionMetrics::class,
|
||||
\App\Filament\Widgets\CouponPerformanceMetrics::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFormSchema(): array
|
||||
{
|
||||
return [
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
DatePicker::make('startDate')
|
||||
->label('Start Date')
|
||||
->default(fn () => now()->subDays(30))
|
||||
->required(),
|
||||
|
||||
DatePicker::make('endDate')
|
||||
->label('End Date')
|
||||
->default(fn () => now())
|
||||
->required(),
|
||||
|
||||
Select::make('selectedProvider')
|
||||
->label('Provider')
|
||||
->options([
|
||||
'all' => 'All Providers',
|
||||
'stripe' => 'Stripe',
|
||||
'lemon_squeezy' => 'Lemon Squeezy',
|
||||
'polar' => 'Polar.sh',
|
||||
'oxapay' => 'OxaPay',
|
||||
'crypto' => 'Crypto',
|
||||
'activation_key' => 'Activation Key',
|
||||
])
|
||||
->default('all'),
|
||||
|
||||
Select::make('selectedPlan')
|
||||
->label('Plan')
|
||||
->options(function () {
|
||||
$plans = DB::table('plans')->pluck('name', 'id');
|
||||
|
||||
return ['all' => 'All Plans'] + $plans->toArray();
|
||||
})
|
||||
->default('all'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(
|
||||
$this->getCustomerAnalyticsQuery()
|
||||
)
|
||||
->columns([
|
||||
TextColumn::make('user_name')
|
||||
->label('Customer')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('medium'),
|
||||
|
||||
TextColumn::make('user_email')
|
||||
->label('Email')
|
||||
->searchable()
|
||||
->copyable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('plan_name')
|
||||
->label('Plan')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->badge()
|
||||
->color('primary'),
|
||||
|
||||
TextColumn::make('provider')
|
||||
->label('Provider')
|
||||
->badge()
|
||||
->colors([
|
||||
'blue' => 'stripe',
|
||||
'green' => 'lemon_squeezy',
|
||||
'purple' => 'polar',
|
||||
'orange' => 'oxapay',
|
||||
'gray' => 'crypto',
|
||||
'pink' => 'activation_key',
|
||||
]),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label('Status')
|
||||
->badge()
|
||||
->colors([
|
||||
'success' => 'active',
|
||||
'warning' => 'trialing',
|
||||
'danger' => 'cancelled',
|
||||
'secondary' => 'paused',
|
||||
'gray' => 'incomplete',
|
||||
]),
|
||||
|
||||
TextColumn::make('subscription_age')
|
||||
->label('Age')
|
||||
->formatStateUsing(function ($record) {
|
||||
$started = $record->starts_at ?? $record->created_at;
|
||||
|
||||
return $started ? $started->diffForHumans() : 'Unknown';
|
||||
})
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('total_coupon_discount')
|
||||
->label('Total Discount')
|
||||
->money('USD')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('trial_extensions_count')
|
||||
->label('Trial Extensions')
|
||||
->formatStateUsing(fn ($record) => $record->trial_extensions_count ?? 0)
|
||||
->alignCenter()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('subscription_changes_count')
|
||||
->label('Changes')
|
||||
->formatStateUsing(fn ($record) => $record->subscription_changes_count ?? 0)
|
||||
->alignCenter()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('mrr')
|
||||
->label('MRR')
|
||||
->money('USD')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('provider')
|
||||
->options([
|
||||
'stripe' => 'Stripe',
|
||||
'lemon_squeezy' => 'Lemon Squeezy',
|
||||
'polar' => 'Polar.sh',
|
||||
'oxapay' => 'OxaPay',
|
||||
'crypto' => 'Crypto',
|
||||
'activation_key' => 'Activation Key',
|
||||
]),
|
||||
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'trialing' => 'Trial',
|
||||
'cancelled' => 'Cancelled',
|
||||
'paused' => 'Paused',
|
||||
'incomplete' => 'Incomplete',
|
||||
]),
|
||||
|
||||
Filter::make('date_range')
|
||||
->form([
|
||||
DatePicker::make('start_date')
|
||||
->label('Start Date')
|
||||
->required(),
|
||||
DatePicker::make('end_date')
|
||||
->label('End Date')
|
||||
->required(),
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
return $query
|
||||
->when(
|
||||
$data['start_date'],
|
||||
fn (Builder $query, $date): Builder => $query->whereDate('subscriptions.created_at', '>=', $date)
|
||||
)
|
||||
->when(
|
||||
$data['end_date'],
|
||||
fn (Builder $query, $date): Builder => $query->whereDate('subscriptions.created_at', '<=', $date)
|
||||
);
|
||||
}),
|
||||
|
||||
Filter::make('has_coupon_usage')
|
||||
->label('Has Coupon Usage')
|
||||
->query(fn (Builder $query): Builder => $query->whereHas('couponUsages')),
|
||||
|
||||
Filter::make('has_trial_extension')
|
||||
->label('Has Trial Extension')
|
||||
->query(fn (Builder $query): Builder => $query->whereHas('trialExtensions')),
|
||||
])
|
||||
->emptyStateHeading('No customer data found')
|
||||
->emptyStateDescription('No customer analytics data available for the selected filters.')
|
||||
->emptyStateActions([
|
||||
Action::make('reset_filters')
|
||||
->label('Reset Filters')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(fn () => $this->resetTable()),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getCustomerAnalyticsQuery(): Builder
|
||||
{
|
||||
return Subscription::query()
|
||||
->with(['user', 'plan', 'couponUsages', 'trialExtensions', 'subscriptionChanges'])
|
||||
->withCount(['couponUsages', 'trialExtensions', 'subscriptionChanges'])
|
||||
->when($this->selectedProvider !== 'all', function ($query) {
|
||||
$query->where('provider', $this->selectedProvider);
|
||||
})
|
||||
->when($this->selectedPlan !== 'all', function ($query) {
|
||||
$query->where('plan_id', $this->selectedPlan);
|
||||
})
|
||||
->when($this->startDate, function ($query) {
|
||||
$query->whereDate('subscriptions.created_at', '>=', $this->startDate);
|
||||
})
|
||||
->when($this->endDate, function ($query) {
|
||||
$query->whereDate('subscriptions.created_at', '<=', $this->endDate);
|
||||
});
|
||||
}
|
||||
|
||||
public function exportReport()
|
||||
{
|
||||
$subscriptions = $this->getCustomerAnalyticsQuery()->get();
|
||||
|
||||
$filename = 'customer_analytics_'.now()->format('Y_m_d_H_i_s').'.csv';
|
||||
|
||||
// Create a temporary file
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
|
||||
// Add BOM for Excel UTF-8 support
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
// Write headers
|
||||
$headers = [
|
||||
'Customer', 'Email', 'Plan', 'Provider', 'Status', 'Subscription Age',
|
||||
'Total Discount', 'Trial Extensions', 'Subscription Changes', 'MRR',
|
||||
'Created At', 'Trial Ends At', 'Ends At',
|
||||
];
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
// Write data rows
|
||||
foreach ($subscriptions as $subscription) {
|
||||
$started = $subscription->starts_at ?? $subscription->created_at;
|
||||
fputcsv($handle, [
|
||||
$subscription->user?->name ?? 'Unknown',
|
||||
$subscription->user?->email ?? 'Unknown',
|
||||
$subscription->plan?->name ?? 'Unknown',
|
||||
$subscription->provider,
|
||||
$subscription->status,
|
||||
$started ? $started->diffForHumans() : 'Unknown',
|
||||
$subscription->couponUsages()->sum('discount_amount'),
|
||||
$subscription->trialExtensions()->count(),
|
||||
$subscription->subscriptionChanges()->count(),
|
||||
$subscription->plan?->monthly_price ?? 0,
|
||||
$subscription->created_at->toDateTimeString(),
|
||||
$subscription->trial_ends_at?->toDateTimeString() ?? 'N/A',
|
||||
$subscription->ends_at?->toDateTimeString() ?? 'N/A',
|
||||
]);
|
||||
}
|
||||
|
||||
// Rewind the file pointer
|
||||
rewind($handle);
|
||||
|
||||
// Get the CSV content
|
||||
$csvContent = stream_get_contents($handle);
|
||||
|
||||
// Close the file handle
|
||||
fclose($handle);
|
||||
|
||||
// Return a download response
|
||||
return response()->streamDownload(
|
||||
function () use ($csvContent) {
|
||||
echo $csvContent;
|
||||
},
|
||||
$filename,
|
||||
[
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user