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:
idevakk
2025-11-19 09:37:00 -08:00
parent 0560016f33
commit 27ac13948c
83 changed files with 15613 additions and 103 deletions

View 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.'"',
]
);
}
}