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,29 @@
<?php
namespace App\Filament\Resources\TrialExtensions\Pages;
use App\Filament\Resources\TrialExtensions\TrialExtensionResource;
use App\Models\Subscription;
use Filament\Resources\Pages\CreateRecord;
class CreateTrialExtension extends CreateRecord
{
protected static string $resource = TrialExtensionResource::class;
/**
* Ensure user_id and original_trial_ends_at are always set from subscription before creating
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
// Always ensure user_id and original_trial_ends_at are set from subscription
if (isset($data['subscription_id'])) {
$subscription = Subscription::find($data['subscription_id']);
if ($subscription) {
$data['user_id'] = $subscription->user_id;
$data['original_trial_ends_at'] = $subscription->trial_ends_at;
}
}
return $data;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TrialExtensions\Pages;
use App\Filament\Resources\TrialExtensions\TrialExtensionResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditTrialExtension extends EditRecord
{
protected static string $resource = TrialExtensionResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TrialExtensions\Pages;
use App\Filament\Resources\TrialExtensions\TrialExtensionResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListTrialExtensions extends ListRecords
{
protected static string $resource = TrialExtensionResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Filament\Resources\TrialExtensions\Schemas;
use App\Models\Subscription;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class TrialExtensionForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Trial Extension Details')
->schema([
Grid::make(2)
->schema([
Select::make('subscription_id')
->label('Subscription')
->options(function () {
return Subscription::with(['user', 'plan'])
->where('status', 'trialing')
->orWhere('status', 'active')
->get()
->mapWithKeys(function ($subscription) {
$label = "{$subscription->user->name} - {$subscription->plan->name}";
if ($subscription->trial_ends_at) {
$label .= " ({$subscription->trial_ends_at->format('M j, Y')})";
}
return [$subscription->id => $label];
})
->toArray();
})
->searchable()
->preload()
->required()
->live()
->afterStateUpdated(function ($state, callable $set, callable $get) {
if ($state) {
$subscription = Subscription::find($state);
if ($subscription) {
$set('user_id', $subscription->user_id);
$set('original_trial_ends_at', $subscription->trial_ends_at);
// Set user display name
if ($subscription->relationLoaded('user')) {
$set('user_display', $subscription->user->name);
} else {
$subscription->load('user');
$set('user_display', $subscription->user->name);
}
self::calculateNewTrialEndDate($set, $get);
}
}
}),
TextInput::make('user_display')
->label('User')
->disabled()
->dehydrated(false),
]),
// Hidden field for user_id to ensure it gets submitted
TextInput::make('user_id')
->hidden(),
Grid::make(2)
->schema([
TextInput::make('extension_days')
->label('Extension Days')
->numeric()
->required()
->default(7)
->live(onBlur: true)
->helperText('Number of days to extend the trial')
->afterStateUpdated(fn ($state, callable $set, callable $get) => self::calculateNewTrialEndDate($set, $get)),
Select::make('extension_type')
->label('Extension Type')
->options([
'manual' => 'Manual Grant',
'automatic' => 'Automatic Extension',
'compensation' => 'Compensation',
])
->default('manual')
->required(),
]),
Grid::make(2)
->schema([
DateTimePicker::make('original_trial_ends_at')
->label('Original Trial End Date')
->disabled()
->dehydrated(false),
DateTimePicker::make('new_trial_ends_at')
->label('New Trial End Date')
->required()
->readOnly()
->helperText('This will be calculated automatically based on extension days'),
]),
]),
Section::make('Additional Information')
->schema([
Textarea::make('reason')
->label('Reason')
->rows(3)
->helperText('Reason for granting this trial extension'),
Select::make('granted_by_admin_id')
->label('Granted By')
->relationship('grantedByAdmin', 'name')
->searchable()
->preload()
->default(fn () => auth()->id())
->helperText('Admin who granted this extension'),
])
->collapsible(),
Section::make('Timestamps')
->schema([
DateTimePicker::make('granted_at')
->label('Granted At')
->default(now())
->required(),
])
->collapsible(),
Section::make('Metadata')
->schema([
KeyValue::make('metadata')
->label('Custom Metadata')
->addActionLabel('Add metadata')
->keyLabel('Key')
->valueLabel('Value')
->helperText('Additional key-value data for this trial extension'),
])
->collapsible(),
]);
}
/**
* Calculate the new trial end date based on original date and extension days
*/
private static function calculateNewTrialEndDate(callable $set, callable $get): void
{
$subscriptionId = $get('subscription_id');
$extensionDays = (int) $get('extension_days') ?: 0;
if (! $subscriptionId || ! $extensionDays) {
$set('new_trial_ends_at', null);
return;
}
$subscription = Subscription::find($subscriptionId);
if (! $subscription) {
$set('new_trial_ends_at', null);
return;
}
// Use the original trial end date if available, otherwise use current date
$baseDate = $subscription->trial_ends_at ?: now();
// Calculate new end date by adding extension days
$newEndDate = $baseDate->copy()->addDays($extensionDays);
// Format for DateTimePicker (Y-m-d H:i:s format)
$set('new_trial_ends_at', $newEndDate->format('Y-m-d H:i:s'));
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Filament\Resources\TrialExtensions\Tables;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class TrialExtensionsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name')
->label('User')
->searchable()
->sortable()
->limit(30),
TextColumn::make('subscription.plan.name')
->label('Plan')
->searchable()
->sortable(),
TextColumn::make('extension_days')
->label('Days Extended')
->sortable()
->alignCenter()
->badge()
->color('success'),
TextColumn::make('extension_type')
->badge()
->label('Type')
->colors([
'blue' => 'manual',
'green' => 'automatic',
'orange' => 'compensation',
]),
TextColumn::make('original_trial_ends_at')
->label('Original End')
->dateTime('M j, Y')
->sortable()
->toggleable(),
TextColumn::make('new_trial_ends_at')
->label('New End')
->dateTime('M j, Y')
->sortable()
->color('success')
->description(fn ($record): string => $record->new_trial_ends_at->diffForHumans()
),
TextColumn::make('reason')
->label('Reason')
->limit(30)
->toggleable(),
TextColumn::make('grantedByAdmin.name')
->label('Granted By')
->searchable()
->sortable()
->toggleable(),
TextColumn::make('granted_at')
->label('Granted At')
->dateTime('M j, Y')
->sortable(),
])
->filters([
SelectFilter::make('extension_type')
->label('Extension Type')
->options([
'manual' => 'Manual Grant',
'automatic' => 'Automatic Extension',
'compensation' => 'Compensation',
]),
SelectFilter::make('granted_by_admin_id')
->label('Granted By')
->relationship('grantedByAdmin', 'name')
->searchable()
->preload(),
])
->recordActions([
EditAction::make(),
Action::make('view_subscription')
->label('View Subscription')
->icon('heroicon-o-rectangle-stack')
->color('blue')
->url(fn ($record) => route('filament.' . filament()->getCurrentPanel()->getId() . '.resources.subscriptions.edit', $record->subscription_id))
->openUrlInNewTab(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->emptyStateActions([
CreateAction::make(),
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\TrialExtensions;
use App\Filament\Resources\TrialExtensions\Pages\CreateTrialExtension;
use App\Filament\Resources\TrialExtensions\Pages\EditTrialExtension;
use App\Filament\Resources\TrialExtensions\Pages\ListTrialExtensions;
use App\Filament\Resources\TrialExtensions\Schemas\TrialExtensionForm;
use App\Filament\Resources\TrialExtensions\Tables\TrialExtensionsTable;
use App\Models\Subscription;
use App\Models\TrialExtension;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class TrialExtensionResource extends Resource
{
protected static ?string $model = TrialExtension::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedClock;
protected static ?string $navigationLabel = 'Trial Extensions';
protected static ?string $modelLabel = 'Trial Extension';
protected static ?string $pluralModelLabel = 'Trial Extensions';
protected static string|UnitEnum|null $navigationGroup = 'Payment Management';
public static function form(Schema $schema): Schema
{
return TrialExtensionForm::configure($schema);
}
public static function table(Table $table): Table
{
return TrialExtensionsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListTrialExtensions::route('/'),
'create' => CreateTrialExtension::route('/create'),
'edit' => EditTrialExtension::route('/{record}/edit'),
];
}
}