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,171 @@
<?php
namespace App\Contracts\Payments;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Http\Request;
interface PaymentProviderContract
{
/**
* Get the provider name/identifier
*/
public function getName(): string;
/**
* Check if provider is properly configured and active
*/
public function isActive(): bool;
/**
* Create a new subscription for a user
*/
public function createSubscription(User $user, Plan $plan, array $options = []): array;
/**
* Cancel an existing subscription
*/
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool;
/**
* Update an existing subscription (e.g., change plan)
*/
public function updateSubscription(Subscription $subscription, Plan $newPlan): array;
/**
* Pause a subscription
*/
public function pauseSubscription(Subscription $subscription): bool;
/**
* Resume a paused subscription
*/
public function resumeSubscription(Subscription $subscription): bool;
/**
* Get subscription details from provider
*/
public function getSubscriptionDetails(string $providerSubscriptionId): array;
/**
* Create a checkout session for one-time payment
*/
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array;
/**
* Create a customer portal session
*/
public function createCustomerPortalSession(User $user): array;
/**
* Process webhook events from provider
*/
public function processWebhook(Request $request): array;
/**
* Validate webhook signature
*/
public function validateWebhook(Request $request): bool;
/**
* Get provider-specific configuration
*/
public function getConfiguration(): array;
/**
* Sync subscription status from provider
*/
public function syncSubscriptionStatus(Subscription $subscription): array;
/**
* Get payment method details
*/
public function getPaymentMethodDetails(string $paymentMethodId): array;
/**
* Handle refund
*/
public function processRefund(string $paymentIntentId, float $amount, string $reason = ''): array;
/**
* Get transaction history
*/
public function getTransactionHistory(User $user, array $filters = []): array;
/**
* Calculate fees for a transaction
*/
public function calculateFees(float $amount): array;
/**
* Get supported currencies
*/
public function getSupportedCurrencies(): array;
/**
* Check if provider supports recurring subscriptions
*/
public function supportsRecurring(): bool;
/**
* Check if provider supports one-time payments
*/
public function supportsOneTime(): bool;
/**
* Get provider-specific metadata for subscription
*/
public function getSubscriptionMetadata(Subscription $subscription): array;
/**
* Update provider-specific metadata
*/
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool;
/**
* Handle subscription trial
*/
public function startTrial(Subscription $subscription, int $trialDays): bool;
/**
* Apply coupon/discount
*/
public function applyCoupon(Subscription $subscription, string $couponCode): array;
/**
* Remove applied coupon
*/
public function removeCoupon(Subscription $subscription): bool;
/**
* Get upcoming invoice details
*/
public function getUpcomingInvoice(Subscription $subscription): array;
/**
* Handle failed payment retry
*/
public function retryFailedPayment(Subscription $subscription): array;
/**
* Check if subscription can be modified
*/
public function canModifySubscription(Subscription $subscription): bool;
/**
* Get subscription cancellation terms
*/
public function getCancellationTerms(Subscription $subscription): array;
/**
* Export subscription data for migration
*/
public function exportSubscriptionData(Subscription $subscription): array;
/**
* Import subscription data from another provider
*/
public function importSubscriptionData(User $user, array $subscriptionData): array;
}

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

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Resources\Coupons;
use App\Filament\Resources\Coupons\Pages\CreateCoupon;
use App\Filament\Resources\Coupons\Pages\EditCoupon;
use App\Filament\Resources\Coupons\Pages\ListCoupons;
use App\Filament\Resources\Coupons\Schemas\CouponForm;
use App\Filament\Resources\Coupons\Tables\CouponsTable;
use App\Models\Coupon;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class CouponResource extends Resource
{
protected static ?string $model = Coupon::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTicket;
protected static ?string $navigationLabel = 'Coupons';
protected static ?string $modelLabel = 'Coupon';
protected static ?string $pluralModelLabel = 'Coupons';
protected static string|UnitEnum|null $navigationGroup = 'Payment Management';
public static function form(Schema $schema): Schema
{
return CouponForm::configure($schema);
}
public static function table(Table $table): Table
{
return CouponsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListCoupons::route('/'),
'create' => CreateCoupon::route('/create'),
'edit' => EditCoupon::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCoupon extends CreateRecord
{
protected static string $resource = CouponResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Filament\Resources\Coupons\Schemas;
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\Forms\Components\Toggle;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
class CouponForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Basic Information')
->schema([
Grid::make(2)
->schema([
TextInput::make('code')
->label('Coupon Code')
->required()
->unique(ignoreRecord: true)
->formatStateUsing(fn ($state) => Str::upper($state))
->helperText('This code will be entered by customers to apply the discount'),
TextInput::make('name')
->label('Display Name')
->required()
->helperText('Internal name for this coupon'),
]),
Textarea::make('description')
->label('Description')
->rows(2)
->helperText('Optional description for internal reference'),
Grid::make(2)
->schema([
Select::make('type')
->label('Discount Type')
->options([
'percentage' => 'Percentage',
'fixed' => 'Fixed Amount',
])
->required()
->reactive()
->afterStateUpdated(fn ($state, callable $set) => $set('value', $state === 'percentage' ? 10 : 10.00)
),
TextInput::make('value')
->label('Discount Value')
->required()
->numeric()
->step(fn ($get) => $get('type') === 'percentage' ? 1 : 0.01)
->suffix(fn ($get) => $get('type') === 'percentage' ? '%' : '$')
->helperText(fn ($get) => $get('type') === 'percentage'
? 'Percentage discount (e.g., 10 for 10%)'
: 'Fixed amount discount in USD'
),
]),
]),
Section::make('Usage Limits')
->schema([
Grid::make(2)
->schema([
TextInput::make('max_uses')
->label('Maximum Uses')
->numeric()
->helperText('Leave blank for unlimited uses'),
TextInput::make('max_uses_per_user')
->label('Max Uses Per User')
->numeric()
->helperText('Limit how many times one user can use this coupon'),
]),
Grid::make(2)
->schema([
TextInput::make('minimum_amount')
->label('Minimum Order Amount')
->numeric()
->step(0.01)
->prefix('$')
->helperText('Minimum order amount required to use this coupon'),
TextInput::make('uses_count')
->label('Current Uses')
->numeric()
->default(0)
->disabled()
->helperText('Number of times this coupon has been used'),
]),
])
->collapsible(),
Section::make('Schedule')
->schema([
Grid::make(2)
->schema([
DateTimePicker::make('starts_at')
->label('Start Date')
->helperText('When this coupon becomes active'),
DateTimePicker::make('expires_at')
->label('Expiration Date')
->helperText('When this coupon expires'),
]),
])
->collapsible(),
Section::make('Settings')
->schema([
Toggle::make('is_active')
->label('Active')
->default(true)
->helperText('Only active coupons can be used'),
])
->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 coupon'),
])
->collapsible(),
]);
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Filament\Resources\Coupons\Tables;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class CouponsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('code')
->label('Code')
->searchable()
->copyable()
->copyMessage('Coupon code copied')
->copyMessageDuration(1500),
TextColumn::make('name')
->label('Name')
->searchable()
->limit(30),
TextColumn::make('type')
->badge()
->label('Type')
->colors([
'blue' => 'percentage',
'green' => 'fixed',
]),
TextColumn::make('formatted_discount')
->label('Discount')
->sortable(),
TextColumn::make('uses_count')
->label('Used')
->sortable()
->alignCenter(),
TextColumn::make('remaining_uses')
->label('Remaining')
->getStateUsing(fn ($record) => $record->remaining_uses ?? '∞')
->sortable()
->alignCenter(),
IconColumn::make('is_active')
->label('Active')
->boolean()
->alignCenter(),
TextColumn::make('expires_at')
->label('Expires')
->dateTime('M j, Y')
->sortable()
->color(fn ($record): string => $record->isExpiringSoon() ? 'warning' : 'default')
->description(fn ($record): string => $record->expires_at ? $record->expires_at->diffForHumans() : ''
),
TextColumn::make('created_at')
->label('Created')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('type')
->options([
'percentage' => 'Percentage',
'fixed' => 'Fixed Amount',
]),
SelectFilter::make('is_active')
->options([
'1' => 'Active',
'0' => 'Inactive',
]),
SelectFilter::make('status')
->label('Status')
->options([
'valid' => 'Valid',
'expired' => 'Expired',
'used_up' => 'Used Up',
])
->query(fn ($query, $data) => match ($data['value']) {
'valid' => $query->valid(),
'expired' => $query->where('expires_at', '<', now()),
'used_up' => $query->whereRaw('uses_count >= max_uses'),
default => $query,
}),
])
->recordActions([
EditAction::make(),
Action::make('duplicate')
->label('Duplicate')
->icon('heroicon-o-document-duplicate')
->color('gray')
->action(function ($record) {
$newCoupon = $record->replicate();
$newCoupon->code = $newCoupon->code.'_COPY';
$newCoupon->uses_count = 0;
$newCoupon->save();
})
->successNotificationTitle('Coupon duplicated successfully'),
Action::make('view_usage')
->label('View Usage')
->icon('heroicon-o-chart-bar')
->color('blue')
->url(fn ($record) => route('filament.admin.resources.coupons.usage', $record)),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
BulkAction::make('bulk_deactivate')
->label('Deactivate')
->icon('heroicon-o-x-circle')
->color('danger')
->action(function (\Illuminate\Support\Collection $records) {
$records->each->update(['is_active' => false]);
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_activate')
->label('Activate')
->icon('heroicon-o-check-circle')
->color('success')
->action(function (\Illuminate\Support\Collection $records) {
$records->each->update(['is_active' => true]);
})
->deselectRecordsAfterCompletion(),
])
->emptyStateActions([
CreateAction::make(),
])
->poll('60s');
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PaymentProviders\Pages;
use App\Filament\Resources\PaymentProviders\PaymentProviderResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePaymentProvider extends CreateRecord
{
protected static string $resource = PaymentProviderResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Filament\Resources\PaymentProviders;
use App\Filament\Resources\PaymentProviders\Pages\CreatePaymentProvider;
use App\Filament\Resources\PaymentProviders\Pages\EditPaymentProvider;
use App\Filament\Resources\PaymentProviders\Pages\ListPaymentProviders;
use App\Filament\Resources\PaymentProviders\Schemas\PaymentProviderForm;
use App\Filament\Resources\PaymentProviders\Tables\PaymentProvidersTable;
use App\Models\PaymentProvider;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class PaymentProviderResource extends Resource
{
protected static ?string $model = PaymentProvider::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCube;
protected static ?string $navigationLabel = 'Payment Providers';
protected static ?string $modelLabel = 'Payment Provider';
protected static ?string $pluralModelLabel = 'Payment Providers';
protected static string|null|\UnitEnum $navigationGroup = 'Payment Management';
public static function form(Schema $schema): Schema
{
return PaymentProviderForm::configure($schema);
}
public static function table(Table $table): Table
{
return PaymentProvidersTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListPaymentProviders::route('/'),
'create' => CreatePaymentProvider::route('/create'),
'edit' => EditPaymentProvider::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Filament\Resources\PaymentProviders\Schemas;
use Filament\Schemas\Components\Grid;
use Filament\Forms\Components\KeyValue;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class PaymentProviderForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Basic Information')
->schema([
Grid::make(2)
->schema([
TextInput::make('name')
->label('Provider Name')
->required()
->unique(ignoreRecord: true)
->helperText('Internal identifier for the provider'),
TextInput::make('display_name')
->label('Display Name')
->required()
->helperText('Name shown to users'),
]),
Textarea::make('description')
->label('Description')
->rows(3)
->helperText('Brief description of the payment provider')
->columnSpanFull(),
]),
Section::make('Capabilities')
->schema([
Grid::make(3)
->schema([
Toggle::make('is_active')
->label('Active')
->default(true)
->helperText('Enable this provider for use'),
Toggle::make('supports_recurring')
->label('Supports Recurring')
->default(false)
->helperText('Can handle subscription payments'),
Toggle::make('supports_one_time')
->label('Supports One-Time')
->default(true)
->helperText('Can handle single payments'),
]),
Grid::make(2)
->schema([
TextInput::make('priority')
->label('Priority')
->numeric()
->default(0)
->helperText('Higher priority = shown first'),
Toggle::make('is_fallback')
->label('Fallback Provider')
->default(false)
->helperText('Default provider when others fail'),
]),
]),
Section::make('Configuration')
->schema([
KeyValue::make('configuration')
->label('Provider Configuration')
->addActionLabel('Add configuration')
->keyLabel('Key')
->valueLabel('Value')
->helperText('API keys and other provider-specific settings')
->columnSpanFull(),
KeyValue::make('supported_currencies')
->label('Supported Currencies')
->addActionLabel('Add currency')
->keyLabel('Currency Code')
->valueLabel('Display Name')
->default(['USD' => 'US Dollar'])
->helperText('Currencies this provider supports')
->columnSpanFull(),
KeyValue::make('fee_structure')
->label('Fee Structure')
->addActionLabel('Add fee setting')
->keyLabel('Fee Type')
->valueLabel('Value')
->helperText('Example: fixed_fee, percentage_fee')
->columnSpanFull(),
]),
Section::make('Webhook Settings')
->schema([
Grid::make(2)
->schema([
TextInput::make('webhook_url')
->label('Webhook URL')
->url()
->helperText('Endpoint for provider webhooks'),
TextInput::make('webhook_secret')
->label('Webhook Secret')
->password()
->helperText('Secret for webhook validation'),
]),
])
->collapsible(),
]);
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Filament\Resources\PaymentProviders\Tables;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\Action;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class PaymentProvidersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Provider')
->searchable()
->sortable()
->weight('bold'),
TextColumn::make('display_name')
->label('Display Name')
->searchable()
->sortable(),
IconColumn::make('is_active')
->label('Active')
->boolean()
->sortable(),
IconColumn::make('supports_recurring')
->label('Recurring')
->boolean()
->sortable(),
IconColumn::make('supports_one_time')
->label('One-Time')
->boolean()
->sortable(),
IconColumn::make('is_fallback')
->label('Fallback')
->boolean()
->sortable()
->color(fn ($record) => $record->is_fallback ? 'warning' : null),
TextColumn::make('priority')
->label('Priority')
->numeric()
->sortable()
->alignCenter(),
TextColumn::make('supported_currencies')
->label('Currencies')
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', array_keys($state)) : '')
->limitList(2)
->separator(', ')
->tooltip(fn ($record) => is_array($record->supported_currencies) ? implode(', ', array_keys($record->supported_currencies)) : ''),
TextColumn::make('created_at')
->label('Created')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->label('Updated')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('is_active')
->label('Status')
->options([
'1' => 'Active',
'0' => 'Inactive',
]),
SelectFilter::make('supports_recurring')
->label('Recurring Support')
->options([
'1' => 'Supports Recurring',
'0' => 'No Recurring Support',
]),
SelectFilter::make('supports_one_time')
->label('One-Time Support')
->options([
'1' => 'Supports One-Time',
'0' => 'No One-Time Support',
]),
SelectFilter::make('is_fallback')
->label('Fallback Status')
->options([
'1' => 'Is Fallback',
'0' => 'Not Fallback',
]),
])
->recordActions([
EditAction::make(),
Action::make('test_connection')
->label('Test Connection')
->icon('heroicon-o-signal')
->color('info')
->action(function ($record) {
$result = $record->testConnection();
if ($result['success']) {
\Filament\Notifications\Notification::make()
->title('Connection Test Successful')
->body('Provider is configured and responding correctly.')
->success()
->send();
} else {
\Filament\Notifications\Notification::make()
->title('Connection Test Failed')
->body($result['error'])
->danger()
->send();
}
}),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
BulkAction::make('activate')
->label('Activate Selected')
->icon('heroicon-o-check')
->action(function ($records) {
$records->each->update(['is_active' => true]);
\Filament\Notifications\Notification::make()
->title('Providers Activated')
->body('Selected payment providers have been activated.')
->success()
->send();
})
->deselectRecordsAfterCompletion(),
BulkAction::make('deactivate')
->label('Deactivate Selected')
->icon('heroicon-o-x-mark')
->action(function ($records) {
$records->where('is_fallback', false)->each->update(['is_active' => false]);
\Filament\Notifications\Notification::make()
->title('Providers Deactivated')
->body('Selected payment providers have been deactivated (fallback providers were skipped).')
->warning()
->send();
})
->deselectRecordsAfterCompletion(),
])
->emptyStateActions([
CreateAction::make(),
])
->emptyStateDescription('No payment providers configured yet.')
->emptyStateHeading('No Payment Providers')
->emptyStateIcon('heroicon-o-rectangle-stack');
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Subscriptions\Pages;
use App\Filament\Resources\Subscriptions\SubscriptionResource;
use Filament\Resources\Pages\CreateRecord;
class CreateSubscription extends CreateRecord
{
protected static string $resource = SubscriptionResource::class;
}

View File

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

View File

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

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Filament\Resources\Subscriptions\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class SubscriptionForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Basic Information')
->schema([
Grid::make(2)
->schema([
Select::make('user_id')
->label('User')
->relationship('user', 'name')
->searchable()
->preload()
->required()
->createOptionForm([
TextInput::make('name')
->required(),
TextInput::make('email')
->email()
->required(),
]),
Select::make('plan_id')
->label('Plan')
->relationship('plan', 'name')
->searchable()
->preload()
->required(),
]),
Grid::make(2)
->schema([
Select::make('type')
->label('Subscription Type')
->options([
'default' => 'Default',
'premium' => 'Premium',
'enterprise' => 'Enterprise',
'trial' => 'Trial',
])
->required()
->default('default')
->helperText('Type of subscription'),
Select::make('status')
->label('Status')
->options([
'active' => 'Active',
'trialing' => 'Trial',
'cancelled' => 'Cancelled',
'paused' => 'Paused',
'incomplete' => 'Incomplete',
])
->required()
->default('active'),
]),
Grid::make(2)
->schema([
Select::make('provider')
->label('Provider')
->options([
'stripe' => 'Stripe',
'lemon_squeezy' => 'Lemon Squeezy',
'polar' => 'Polar.sh',
'oxapay' => 'OxaPay',
'crypto' => 'Crypto',
'activation_key' => 'Activation Key',
])
->required()
->default('stripe'),
TextInput::make('provider_subscription_id')
->label('Provider Subscription ID'),
]),
]),
Section::make('Trial Management')
->schema([
Grid::make(2)
->schema([
DateTimePicker::make('trial_ends_at')
->label('Trial Ends At'),
TextInput::make('quantity')
->label('Quantity')
->numeric()
->default(1),
]),
])
->collapsible(),
Section::make('Billing Dates')
->schema([
Grid::make(2)
->schema([
DateTimePicker::make('starts_at')
->label('Starts At'),
DateTimePicker::make('ends_at')
->label('Ends At'),
]),
Grid::make(2)
->schema([
DateTimePicker::make('cancelled_at')
->label('Cancelled At'),
DateTimePicker::make('paused_at')
->label('Paused At'),
]),
Grid::make(2)
->schema([
DateTimePicker::make('resumed_at')
->label('Resumed At'),
DateTimePicker::make('last_provider_sync')
->label('Last Provider Sync'),
]),
])
->collapsible(),
Section::make('Cancellation Details')
->schema([
Textarea::make('cancellation_reason')
->label('Reason'),
])
->collapsible()
->visible(fn ($get) => $get('status') === 'cancelled'),
Section::make('Provider Information')
->schema([
TextInput::make('stripe_id')
->label('Stripe ID')
->visible(fn ($get) => $get('provider') === 'stripe'),
TextInput::make('stripe_status')
->label('Stripe Status')
->visible(fn ($get) => $get('provider') === 'stripe'),
TextInput::make('stripe_price')
->label('Stripe Price')
->visible(fn ($get) => $get('provider') === 'stripe'),
Textarea::make('provider_data')
->label('Provider Data')
->rows(3)
->helperText('JSON data from the payment provider'),
])
->collapsible(),
Section::make('Migration Information')
->schema([
TextInput::make('migration_batch_id')
->label('Migration Batch ID'),
Toggle::make('is_migrated')
->label('Is Migrated'),
Textarea::make('legacy_data')
->label('Legacy Data')
->rows(3),
])
->collapsible(),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Subscriptions;
use App\Filament\Resources\Subscriptions\Pages\CreateSubscription;
use App\Filament\Resources\Subscriptions\Pages\EditSubscription;
use App\Filament\Resources\Subscriptions\Pages\ListSubscriptions;
use App\Filament\Resources\Subscriptions\Schemas\SubscriptionForm;
use App\Filament\Resources\Subscriptions\Tables\SubscriptionsTable;
use App\Models\Subscription;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class SubscriptionResource extends Resource
{
protected static ?string $model = Subscription::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentCurrencyDollar;
protected static string|null|\UnitEnum $navigationGroup = 'Payment Management';
public static function form(Schema $schema): Schema
{
return SubscriptionForm::configure($schema);
}
public static function table(Table $table): Table
{
return SubscriptionsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListSubscriptions::route('/'),
'create' => CreateSubscription::route('/create'),
'edit' => EditSubscription::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace App\Filament\Resources\Subscriptions\Tables;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class SubscriptionsTable
{
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(function ($query) {
$query->withSum('couponUsages as total_coupon_discount', 'discount_amount');
})
->columns([
TextColumn::make('user.name')
->label('User')
->searchable()
->sortable()
->limit(30),
TextColumn::make('plan.name')
->label('Plan')
->searchable()
->sortable(),
TextColumn::make('type')
->badge()
->label('Type')
->colors([
'gray' => 'default',
'blue' => 'premium',
'purple' => 'enterprise',
'warning' => 'trial',
]),
TextColumn::make('status')
->badge()
->label('Status')
->colors([
'success' => 'active',
'warning' => 'trialing',
'danger' => 'cancelled',
'secondary' => 'paused',
'gray' => 'incomplete',
]),
TextColumn::make('provider')
->badge()
->label('Provider')
->colors([
'blue' => 'stripe',
'green' => 'lemon_squeezy',
'purple' => 'polar',
'orange' => 'oxapay',
'gray' => 'crypto',
'pink' => 'activation_key',
]),
TextColumn::make('provider_subscription_id')
->label('Provider ID')
->searchable()
->limit(20),
TextColumn::make('trial_ends_at')
->label('Trial Ends')
->dateTime('M j, Y')
->sortable()
->color('warning')
->description(fn ($record): string => $record->isOnTrial() ? $record->trial_ends_at->diffForHumans() : ''
),
TextColumn::make('ends_at')
->label('Ends At')
->dateTime('M j, Y')
->sortable()
->color(fn ($record): string => $record->ends_at && $record->ends_at->isPast() ? 'danger' : 'default'
),
TextColumn::make('quantity')
->label('Qty')
->numeric()
->sortable()
->toggleable(),
IconColumn::make('hasExtendedTrial')
->label('Trial Extended')
->boolean()
->getStateUsing(fn ($record) => $record->hasExtendedTrial())
->toggleable(),
TextColumn::make('total_coupon_discount')
->label('Total Discount')
->money('USD')
->sortable()
->toggleable(),
TextColumn::make('created_at')
->label('Created')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('last_provider_sync')
->label('Last Sync')
->dateTime('M j, Y')
->sortable()
->toggleable(),
])
->filters([
SelectFilter::make('status')
->options([
'active' => 'Active',
'trialing' => 'Trial',
'cancelled' => 'Cancelled',
'paused' => 'Paused',
'incomplete' => 'Incomplete',
]),
SelectFilter::make('type')
->label('Subscription Type')
->options([
'default' => 'Default',
'premium' => 'Premium',
'enterprise' => 'Enterprise',
'trial' => 'Trial',
]),
SelectFilter::make('provider')
->options([
'stripe' => 'Stripe',
'lemon_squeezy' => 'Lemon Squeezy',
'polar' => 'Polar.sh',
'oxapay' => 'OxaPay',
'crypto' => 'Crypto',
'activation_key' => 'Activation Key',
]),
SelectFilter::make('has_trial_extension')
->label('Has Trial Extension')
->options([
'yes' => 'Yes',
'no' => 'No',
])
->query(fn ($query, $data) => match ($data['value']) {
'yes' => $query->whereHas('trialExtensions'),
'no' => $query->whereDoesntHave('trialExtensions'),
default => $query,
}),
])
->recordActions([
EditAction::make(),
Action::make('extend_trial')
->label('Extend Trial')
->icon('heroicon-o-clock')
->color('warning')
->schema([
\Filament\Forms\Components\TextInput::make('days')
->label('Days to Extend')
->numeric()
->required()
->default(7),
\Filament\Forms\Components\TextInput::make('reason')
->label('Reason')
->required(),
\Filament\Forms\Components\Select::make('extension_type')
->label('Extension Type')
->options([
'manual' => 'Manual Grant',
'automatic' => 'Automatic Extension',
'compensation' => 'Compensation',
])
->default('manual'),
])
->action(function (array $data, $record) {
$record->extendTrial(
(int) $data['days'],
$data['reason'],
$data['extension_type'],
auth()->user()
);
})
->visible(fn ($record) => $record->isOnTrial()),
])
->toolbarActions([
BulkAction::make('bulk_extend_trial')
->label('Bulk Extend Trial')
->icon('heroicon-o-clock')
->color('warning')
->schema([
\Filament\Forms\Components\TextInput::make('days')
->label('Days to Extend')
->numeric()
->required()
->default(7),
\Filament\Forms\Components\TextInput::make('reason')
->label('Reason')
->required(),
])
->action(function (array $data, \Illuminate\Support\Collection $records) {
foreach ($records as $record) {
if ($record->isOnTrial()) {
$record->extendTrial(
(int) $data['days'],
$data['reason'],
'manual',
auth()->user()
);
}
}
})
->deselectRecordsAfterCompletion(),
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->emptyStateActions([
CreateAction::make(),
]);
}
}

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

View File

@@ -0,0 +1,150 @@
<?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
{
return Subscription::query()
->select(
'provider',
DB::raw('COUNT(CASE WHEN status = "cancelled" THEN 1 END) as cancelled'),
DB::raw('COUNT(*) as total'),
DB::raw('(CAST(COUNT(CASE WHEN status = "cancelled" THEN 1 END) AS REAL) * 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
{
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('(CAST(COUNT(CASE WHEN subscriptions.status = "cancelled" THEN 1 END) AS REAL) * 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();
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Coupon;
use App\Models\CouponUsage;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\DB;
class CouponPerformanceMetrics extends BaseWidget
{
protected static ?int $sort = 3;
protected function getStats(): array
{
$totalCoupons = Coupon::count();
$activeCoupons = Coupon::where('is_active', true)->count();
$totalUsages = CouponUsage::count();
$totalDiscount = CouponUsage::sum('discount_amount');
$conversionRate = $this->getCouponConversionRate();
$avgDiscountValue = $totalUsages > 0 ? $totalDiscount / $totalUsages : 0;
$topPerformingCoupon = $this->getTopPerformingCoupon();
$monthlyUsage = $this->getMonthlyUsage();
return [
Stat::make('Total Coupons', $totalCoupons)
->description($activeCoupons.' active')
->descriptionIcon('heroicon-o-ticket')
->color('primary'),
Stat::make('Total Usages', $totalUsages)
->description($this->getUsageGrowthRate())
->descriptionIcon($this->getUsageGrowthIcon())
->color($this->getUsageGrowthColor()),
Stat::make('Total Discount Given', '$'.number_format($totalDiscount, 2))
->description('Total value of discounts')
->descriptionIcon('heroicon-o-gift')
->color('success'),
Stat::make('Conversion Rate', $conversionRate.'%')
->description('Coupon to subscription rate')
->descriptionIcon('heroicon-o-chart-bar')
->color('info'),
Stat::make('Avg Discount Value', '$'.number_format($avgDiscountValue, 2))
->description('Per usage average')
->descriptionIcon('heroicon-o-calculator')
->color('warning'),
Stat::make('Top Performing', $topPerformingCoupon ? ($topPerformingCoupon['name'] ?? 'N/A') : 'N/A')
->description($topPerformingCoupon ? ($topPerformingCoupon['usages'] ?? 0).' usages' : '0 usages')
->descriptionIcon('heroicon-o-trophy')
->color('purple'),
Stat::make('Monthly Usage', $monthlyUsage)
->description('This month')
->descriptionIcon('heroicon-o-calendar')
->color('success'),
Stat::make('Revenue Impact', '$'.number_format($this->calculateRevenueImpact(), 2))
->description('Estimated new revenue')
->descriptionIcon('heroicon-o-currency-dollar')
->color('success'),
];
}
private function getCouponConversionRate(): string
{
$totalCoupons = Coupon::count();
if ($totalCoupons == 0) {
return '0';
}
$usedCoupons = Coupon::whereHas('usages')->count();
return number_format(($usedCoupons / $totalCoupons) * 100, 1);
}
private function getUsageGrowthRate(): string
{
$currentMonth = CouponUsage::whereMonth('created_at', now()->month)->count();
$previousMonth = CouponUsage::whereMonth('created_at', now()->subMonth()->month)->count();
if ($previousMonth == 0) {
return 'New this month';
}
$growth = (($currentMonth - $previousMonth) / $previousMonth) * 100;
return $growth >= 0 ? "+{$growth}%" : "{$growth}%";
}
private function getUsageGrowthIcon(): string
{
$currentMonth = CouponUsage::whereMonth('created_at', now()->month)->count();
$previousMonth = CouponUsage::whereMonth('created_at', now()->subMonth()->month)->count();
return $currentMonth > $previousMonth ? 'heroicon-o-arrow-trending-up' : 'heroicon-o-arrow-trending-down';
}
private function getUsageGrowthColor(): string
{
$currentMonth = CouponUsage::whereMonth('created_at', now()->month)->count();
$previousMonth = CouponUsage::whereMonth('created_at', now()->subMonth()->month)->count();
return $currentMonth > $previousMonth ? 'success' : 'danger';
}
private function getTopPerformingCoupon(): ?array
{
return CouponUsage::query()
->select('coupon_id', DB::raw('count(*) as usages, sum(discount_amount) as total_discount'))
->with('coupon:id,code')
->groupBy('coupon_id')
->orderBy('usages', 'desc')
->first()
?->toArray();
}
private function getMonthlyUsage(): int
{
return CouponUsage::whereMonth('created_at', now()->month)->count();
}
private function calculateRevenueImpact(): float
{
// Estimate revenue from coupons that led to subscriptions
return CouponUsage::query()
->whereHas('subscription', function ($query) {
$query->where('status', 'active');
})
->join('subscriptions', 'coupon_usages.subscription_id', '=', 'subscriptions.id')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->sum('plans.price');
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Subscription;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class CustomerAnalyticsOverview extends BaseWidget
{
protected function getStats(): array
{
$totalCustomers = User::count();
$payingCustomers = Subscription::active()->distinct('user_id')->count('user_id');
$trialCustomers = Subscription::onTrial()->distinct('user_id')->count('user_id');
$churnedCustomers = Subscription::where('status', 'cancelled')->distinct('user_id')->count('user_id');
$mrr = $this->calculateMRR();
$arr = $mrr * 12;
$arpu = $payingCustomers > 0 ? $mrr / $payingCustomers : 0;
$ltv = $arpu * 12; // Simplified LTV calculation
return [
Stat::make('Total Customers', $totalCustomers)
->description('All registered users')
->descriptionIcon('heroicon-o-users')
->color('primary'),
Stat::make('Paying Customers', $payingCustomers)
->description($this->getCustomerGrowthRate($payingCustomers))
->descriptionIcon($this->getGrowthIcon($payingCustomers))
->color($this->getGrowthColor($payingCustomers)),
Stat::make('Trial Customers', $trialCustomers)
->description('Currently on trial')
->descriptionIcon('heroicon-o-clock')
->color('warning'),
Stat::make('Churned Customers', $churnedCustomers)
->description($this->getChurnRate($churnedCustomers, $payingCustomers + $churnedCustomers))
->descriptionIcon('heroicon-o-arrow-trending-down')
->color('danger'),
Stat::make('Monthly Recurring Revenue', '$'.number_format($mrr, 2))
->description('MRR from active subscriptions')
->descriptionIcon('heroicon-o-currency-dollar')
->color('success'),
Stat::make('Annual Recurring Revenue', '$'.number_format($arr, 2))
->description('ARR projection')
->descriptionIcon('heroicon-o-chart-bar')
->color('success'),
Stat::make('Average Revenue Per User', '$'.number_format($arpu, 2))
->description('ARPU for paying customers')
->descriptionIcon('heroicon-o-calculator')
->color('info'),
Stat::make('Lifetime Value', '$'.number_format($ltv, 2))
->description('Estimated customer LTV')
->descriptionIcon('heroicon-o-gift')
->color('purple'),
];
}
private function calculateMRR(): float
{
return Subscription::active()
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->sum('plans.price');
}
private function getCustomerGrowthRate(int $current): string
{
$previous = Subscription::active()
->where('created_at', '<', now()->subMonth())
->distinct('user_id')
->count('user_id');
if ($previous == 0) {
return 'New customers';
}
$growth = (($current - $previous) / $previous) * 100;
return $growth >= 0 ? "+{$growth}%" : "{$growth}%";
}
private function getGrowthIcon(int $current): string
{
$previous = Subscription::active()
->where('created_at', '<', now()->subMonth())
->distinct('user_id')
->count('user_id');
return $current > $previous ? 'heroicon-o-arrow-trending-up' : 'heroicon-o-arrow-trending-down';
}
private function getGrowthColor(int $current): string
{
$previous = Subscription::active()
->where('created_at', '<', now()->subMonth())
->distinct('user_id')
->count('user_id');
return $current > $previous ? 'success' : 'danger';
}
private function getChurnRate(int $churned, int $total): string
{
if ($total == 0) {
return '0% churn rate';
}
$rate = ($churned / $total) * 100;
return "{$rate}% churn rate";
}
}

View File

@@ -0,0 +1,225 @@
<?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);
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Subscription;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
class RevenueMetrics extends ChartWidget
{
protected static ?int $sort = 1;
protected int|string|array $columnSpan = 'full';
public function getHeading(): string
{
return 'Revenue Trends';
}
protected function getData(): array
{
$monthlyRevenue = $this->getMonthlyRevenueTrend();
$mrrByProvider = $this->getMRRByProvider();
return [
'datasets' => [
[
'label' => 'Monthly Revenue',
'data' => array_values($monthlyRevenue),
'borderColor' => 'rgba(34, 197, 94, 1)',
'backgroundColor' => 'rgba(34, 197, 94, 0.1)',
'fill' => true,
'tension' => 0.4,
],
[
'label' => 'MRR by Provider',
'data' => array_values($mrrByProvider),
'borderColor' => 'rgba(59, 130, 246, 1)',
'backgroundColor' => 'rgba(59, 130, 246, 0.1)',
'fill' => true,
'tension' => 0.4,
],
],
'labels' => array_keys($monthlyRevenue),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): array
{
return [
'responsive' => true,
'interaction' => [
'intersect' => false,
'mode' => 'index',
],
'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 getMonthlyRevenueTrend(): array
{
return Subscription::query()
->select(
DB::raw("strftime('%Y-%m', subscriptions.created_at) as month"),
DB::raw('SUM(plans.price) as revenue')
)
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->where('subscriptions.status', 'active')
->where('subscriptions.created_at', '>=', now()->subMonths(12))
->groupBy('month')
->orderBy('month')
->pluck('revenue', 'month')
->toArray();
}
private function getMRRByProvider(): array
{
return Subscription::query()
->select(
'provider',
DB::raw('SUM(plans.price) as mrr')
)
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->where('subscriptions.status', 'active')
->groupBy('provider')
->orderBy('mrr', 'desc')
->pluck('mrr', 'provider')
->toArray();
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Subscription;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
class SubscriptionMetrics extends ChartWidget
{
protected static ?int $sort = 2;
protected int|string|array $columnSpan = 'full';
public function getHeading(): string
{
return 'Subscription Metrics';
}
protected function getData(): array
{
$period = $this->getPeriod();
$subscriptionsByProvider = $this->getSubscriptionsByProvider($period);
$subscriptionsByStatus = $this->getSubscriptionsByStatus();
$monthlyTrend = $this->getMonthlySubscriptionTrend();
return [
'datasets' => [
[
'label' => 'Subscriptions by Provider',
'data' => array_values($subscriptionsByProvider),
'backgroundColor' => [
'rgba(59, 130, 246, 0.8)', // blue
'rgba(34, 197, 94, 0.8)', // green
'rgba(168, 85, 247, 0.8)', // purple
'rgba(251, 146, 60, 0.8)', // orange
'rgba(107, 114, 128, 0.8)', // gray
'rgba(236, 72, 153, 0.8)', // pink
],
'borderColor' => [
'rgba(59, 130, 246, 1)',
'rgba(34, 197, 94, 1)',
'rgba(168, 85, 247, 1)',
'rgba(251, 146, 60, 1)',
'rgba(107, 114, 128, 1)',
'rgba(236, 72, 153, 1)',
],
],
],
'labels' => array_keys($subscriptionsByProvider),
];
}
protected function getType(): string
{
return 'doughnut';
}
protected function getOptions(): array
{
return [
'responsive' => true,
'plugins' => [
'legend' => [
'position' => 'bottom',
],
'tooltip' => [
'callbacks' => [
'label' => 'function(context) {
const label = context.label || "";
const value = context.parsed || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return label + ": " + value + " (" + percentage + "%)";
}',
],
],
],
];
}
private function getPeriod(): string
{
return 'last_30_days'; // Could be made configurable
}
private function getSubscriptionsByProvider(string $period): array
{
$query = Subscription::query();
if ($period === 'last_30_days') {
$query->where('created_at', '>=', now()->subDays(30));
}
return $query
->select('provider', DB::raw('count(*) as count'))
->groupBy('provider')
->orderBy('count', 'desc')
->pluck('count', 'provider')
->toArray();
}
private function getSubscriptionsByStatus(): array
{
return Subscription::query()
->select('status', DB::raw('count(*) as count'))
->groupBy('status')
->orderBy('count', 'desc')
->pluck('count', 'status')
->toArray();
}
private function getMonthlySubscriptionTrend(): array
{
return Subscription::query()
->select(
DB::raw("strftime('%Y-%m', subscriptions.created_at) as month"),
DB::raw('count(*) as count')
)
->groupBy('month')
->orderBy('month')
->pluck('count', 'month')
->toArray();
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Subscription;
use App\Models\TrialExtension;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
class TrialPerformance extends ChartWidget
{
protected static ?int $sort = 4;
protected int|string|array $columnSpan = 'full';
public function getHeading(): string
{
return 'Trial Performance';
}
protected function getData(): array
{
$trialConversion = $this->getTrialConversionRates();
$trialExtensions = $this->getTrialExtensionsTrend();
return [
'datasets' => [
[
'label' => 'Trial Conversion Rate (%)',
'data' => array_values($trialConversion),
'borderColor' => 'rgba(168, 85, 247, 1)',
'backgroundColor' => 'rgba(168, 85, 247, 0.1)',
'fill' => true,
'tension' => 0.4,
],
[
'label' => 'Trial Extensions',
'data' => array_values($trialExtensions),
'borderColor' => 'rgba(251, 146, 60, 1)',
'backgroundColor' => 'rgba(251, 146, 60, 0.1)',
'fill' => true,
'tension' => 0.4,
],
],
'labels' => array_keys($trialConversion),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): array
{
return [
'responsive' => true,
'interaction' => [
'intersect' => false,
'mode' => 'index',
],
'plugins' => [
'legend' => [
'position' => 'top',
],
'tooltip' => [
'callbacks' => [
'label' => 'function(context) {
let label = context.dataset.label || "";
if (label) {
label += ": ";
}
if (context.parsed.y !== null) {
if (label.includes("Rate")) {
label += context.parsed.y.toFixed(1) + "%";
} else {
label += context.parsed.y;
}
}
return label;
}',
],
],
],
'scales' => [
'y' => [
'beginAtZero' => true,
'ticks' => [
'callback' => 'function(value, index, values) {
if (index === 0 || index === values.length - 1) {
return value + (this.chart.data.datasets[0].label.includes("Rate") ? "%" : "");
}
return "";
}',
],
],
],
];
}
private function getTrialConversionRates(): array
{
$conversionData = [];
for ($i = 5; $i >= 0; $i--) {
$month = now()->subMonths($i);
$monthStart = $month->copy()->startOfMonth();
$monthEnd = $month->copy()->endOfMonth();
// Trials that ended during this month
$endedTrials = Subscription::query()
->where('status', 'trialing')
->whereBetween('trial_ends_at', [$monthStart, $monthEnd])
->get();
$totalTrials = $endedTrials->count();
$convertedTrials = $endedTrials->filter(function ($trial) use ($monthEnd) {
// Check if trial converted to paid subscription
return Subscription::query()
->where('user_id', $trial->user_id)
->where('status', 'active')
->where('created_at', '>', $trial->trial_ends_at)
->where('created_at', '<=', $monthEnd)
->exists();
})->count();
$conversionRate = $totalTrials > 0 ? ($convertedTrials / $totalTrials) * 100 : 0;
$conversionData[$month->format('M Y')] = round($conversionRate, 2);
}
return $conversionData;
}
private function getTrialExtensionsTrend(): array
{
return TrialExtension::query()
->select(
DB::raw("strftime('%Y-%m', trial_extensions.granted_at) as month"),
DB::raw('COUNT(*) as extensions'),
DB::raw('SUM(extension_days) as total_days')
)
->where('trial_extensions.granted_at', '>=', now()->subMonths(6))
->groupBy('month')
->orderBy('month')
->get()
->mapWithKeys(function ($item) {
$date = \Carbon\Carbon::createFromFormat('Y-m', $item->month);
return [$date->format('M Y') => $item->extensions];
})
->toArray();
}
private function getAverageTrialLength(): float
{
return Subscription::query()
->where('status', 'trialing')
->orWhere('status', 'active')
->whereNotNull('trial_ends_at')
->selectRaw('AVG(DATEDIFF(trial_ends_at, created_at)) as avg_trial_days')
->value('avg_trial_days') ?? 0;
}
private function getMostCommonExtensionReasons(): array
{
return TrialExtension::query()
->select('reason', DB::raw('COUNT(*) as count'))
->whereNotNull('reason')
->groupBy('reason')
->orderBy('count', 'desc')
->limit(5)
->pluck('count', 'reason')
->toArray();
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers;
use App\Models\Plan;
use App\Models\User;
use App\Services\Payments\PaymentOrchestrator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class PaymentController extends Controller
{
public function __construct(
private PaymentOrchestrator $orchestrator
) {}
/**
* Create a checkout session for a plan
*/
public function createCheckout(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'plan_id' => 'required|exists:plans,id',
'provider' => 'nullable|string|in:stripe,lemon_squeezy,polar,oxapay,crypto,activation_key',
'options' => 'nullable|array',
]);
$user = $request->user();
$plan = Plan::findOrFail($validated['plan_id']);
$provider = $validated['provider'] ?? null;
$options = $validated['options'] ?? [];
$result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $options);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Create a new subscription
*/
public function createSubscription(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'plan_id' => 'required|exists:plans,id',
'provider' => 'nullable|string|in:stripe,lemon_squeezy,polar',
'options' => 'nullable|array',
]);
$user = $request->user();
$plan = Plan::findOrFail($validated['plan_id']);
$provider = $validated['provider'] ?? null;
$options = $validated['options'] ?? [];
// Only recurring providers can create subscriptions
if (! $plan->monthly_billing) {
return response()->json([
'success' => false,
'error' => 'This plan does not support recurring subscriptions. Use checkout instead.',
], 400);
}
$result = $this->orchestrator->createSubscription($user, $plan, $provider, $options);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Get available payment methods for a plan
*/
public function getPaymentMethods(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'plan_id' => 'required|exists:plans,id',
]);
$plan = Plan::findOrFail($validated['plan_id']);
$providers = $this->orchestrator->getActiveProvidersForPlan($plan);
$methods = $providers->map(function ($provider) use ($plan) {
return [
'provider' => $provider->getName(),
'name' => $provider->getName(),
'supports_recurring' => $provider->supportsRecurring(),
'supports_one_time' => $provider->supportsOneTime(),
'supported_currencies' => $provider->getSupportedCurrencies(),
'fees' => $provider->calculateFees($plan->price),
'active' => $provider->isActive(),
];
})->values()->toArray();
return response()->json([
'success' => true,
'data' => [
'plan' => [
'id' => $plan->id,
'name' => $plan->name,
'price' => $plan->price,
'monthly_billing' => $plan->monthly_billing,
],
'payment_methods' => $methods,
],
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Get user's payment/subscription history
*/
public function getHistory(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'limit' => 'nullable|integer|min:1|max:100',
'offset' => 'nullable|integer|min:0',
'filters' => 'nullable|array',
]);
$user = $request->user();
$limit = $validated['limit'] ?? 20;
$filters = $validated['filters'] ?? [];
$history = $this->orchestrator->getTransactionHistory($user, $filters);
// Apply pagination
$offset = $validated['offset'] ?? 0;
$paginatedHistory = array_slice($history, $offset, $limit);
return response()->json([
'success' => true,
'data' => [
'transactions' => $paginatedHistory,
'pagination' => [
'total' => count($history),
'limit' => $limit,
'offset' => $offset,
'has_more' => $offset + $limit < count($history),
],
],
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Handle successful payment redirect
*/
public function success(Request $request): JsonResponse
{
return response()->json([
'status' => 'success',
'message' => 'Payment completed successfully',
]);
}
/**
* Handle cancelled payment redirect
*/
public function cancel(Request $request): JsonResponse
{
return response()->json([
'status' => 'cancelled',
'message' => 'Payment was cancelled',
]);
}
/**
* Handle payment provider webhooks
*/
public function webhook(Request $request, string $provider): JsonResponse
{
try {
$result = $this->orchestrator->processWebhook($provider, $request);
return response()->json([
'status' => 'processed',
'result' => $result,
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
], 400);
}
}
}

View File

@@ -0,0 +1,297 @@
<?php
namespace App\Http\Controllers;
use App\Models\ActivationKey;
use App\Services\Payments\PaymentConfigurationManager;
use App\Services\Payments\PaymentOrchestrator;
use App\Services\Payments\Providers\ActivationKeyProvider;
use App\Services\Payments\Providers\CryptoProvider;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class PaymentProviderController extends Controller
{
public function __construct(
private PaymentConfigurationManager $configManager,
private PaymentOrchestrator $orchestrator
) {}
/**
* Get all payment providers and their status
*/
public function index(): JsonResponse
{
try {
$status = $this->configManager->getProviderStatus();
$stats = $this->orchestrator->getRegistry()->getProviderStats();
return response()->json([
'providers' => $status,
'statistics' => $stats,
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Failed to retrieve provider status',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Get specific provider details
*/
public function show(string $provider): JsonResponse
{
try {
$providerInstance = $this->orchestrator->getRegistry()->get($provider);
$config = $this->configManager->getProviderConfig($provider);
return response()->json([
'provider' => $provider,
'name' => $providerInstance->getName(),
'active' => $providerInstance->isActive(),
'configuration' => $this->configManager->sanitizeConfig($config),
'capabilities' => [
'supports_recurring' => $providerInstance->supportsRecurring(),
'supports_one_time' => $providerInstance->supportsOneTime(),
'supported_currencies' => $providerInstance->getSupportedCurrencies(),
],
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Provider not found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* Test provider connectivity
*/
public function test(string $provider): JsonResponse
{
try {
$result = $this->configManager->testProviderConnectivity($provider);
return response()->json($result);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Failed to test provider',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Enable/disable a provider
*/
public function toggle(Request $request, string $provider): JsonResponse
{
$validated = $request->validate([
'enabled' => 'required|boolean',
]);
try {
$result = $this->configManager->toggleProvider($provider, $validated['enabled']);
if ($result) {
return response()->json([
'success' => true,
'message' => "Provider {$provider} has been ".
($validated['enabled'] ? 'enabled' : 'disabled'),
]);
}
return response()->json([
'success' => false,
'message' => "Failed to toggle provider {$provider}",
], 400);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Failed to toggle provider',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Update provider configuration
*/
public function updateConfig(Request $request, string $provider): JsonResponse
{
$config = $request->all();
// Validate configuration
$validation = $this->configManager->validateProviderConfig($provider, $config);
if (! $validation['valid']) {
throw ValidationException::withMessages([
'config' => $validation['errors'],
]);
}
try {
$this->configManager->updateProviderConfig($provider, $config);
return response()->json([
'success' => true,
'message' => "Configuration updated for provider {$provider}",
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Failed to update configuration',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Refresh provider configurations
*/
public function refresh(): JsonResponse
{
try {
$this->configManager->refreshConfigurations();
return response()->json([
'success' => true,
'message' => 'Provider configurations refreshed',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Failed to refresh configurations',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Redeem an activation key
*/
public function redeemActivationKey(Request $request): JsonResponse
{
$validated = $request->validate([
'activation_key' => 'required|string',
]);
try {
$user = $request->user();
if (! $user) {
return response()->json([
'success' => false,
'error' => 'Authentication required',
], 401);
}
$provider = new ActivationKeyProvider;
$result = $provider->redeemActivationKey($validated['activation_key'], $user);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Failed to redeem activation key',
'message' => $e->getMessage(),
], 400);
}
}
/**
* Validate an activation key
*/
public function validateActivationKey(string $key): JsonResponse
{
try {
$activationKey = ActivationKey::where('activation_key', $key)->first();
if (! $activationKey) {
return response()->json([
'valid' => false,
'reason' => 'Activation key not found',
]);
}
return response()->json([
'valid' => true,
'is_activated' => $activationKey->is_activated,
'created_at' => $activationKey->created_at,
'plan_id' => $activationKey->price_id,
]);
} catch (\Exception $e) {
return response()->json([
'valid' => false,
'error' => 'Failed to validate activation key',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Get cryptocurrency exchange rate
*/
public function getCryptoRate(string $crypto): JsonResponse
{
try {
$provider = new CryptoProvider;
// Test conversion with $1 USD to get rate
$amount = $provider->convertUsdToCrypto(1.00, strtoupper($crypto));
$rate = 1 / $amount; // Invert to get USD per crypto
return response()->json([
'crypto' => strtoupper($crypto),
'rate_usd_per_crypto' => $rate,
'rate_crypto_per_usd' => $amount,
'updated_at' => now()->toISOString(),
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Failed to get crypto rate',
'message' => $e->getMessage(),
], 400);
}
}
/**
* Convert USD to cryptocurrency
*/
public function convertUsdToCrypto(Request $request): JsonResponse
{
$validated = $request->validate([
'usd_amount' => 'required|numeric|min:0.01',
'crypto' => 'required|string|in:BTC,ETH,USDT,USDC,LTC',
]);
try {
$provider = new CryptoProvider;
$cryptoAmount = $provider->convertUsdToCrypto(
$validated['usd_amount'],
strtoupper($validated['crypto'])
);
$fees = $provider->calculateFees($validated['usd_amount']);
return response()->json([
'usd_amount' => $validated['usd_amount'],
'crypto' => strtoupper($validated['crypto']),
'crypto_amount' => $cryptoAmount,
'fees' => $fees,
'net_crypto_amount' => $cryptoAmount, // Crypto providers typically don't deduct fees from amount
'updated_at' => now()->toISOString(),
]);
} catch (\Exception $e) {
return response()->json([
'error' => 'Failed to convert USD to crypto',
'message' => $e->getMessage(),
], 400);
}
}
}

View File

@@ -2,118 +2,626 @@
namespace App\Http\Controllers;
use App\Models\Coupon;
use App\Models\Subscription;
use App\NotifyMe;
use App\Services\Payments\PaymentOrchestrator;
use Exception;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Date;
use App\NotifyMe;
use Exception;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
use NotifyMe;
public function oxapay(Request $request): ResponseFactory|Response
public function __construct(
private PaymentOrchestrator $orchestrator
) {}
/**
* Unified webhook handler for all payment providers
*/
public function handle(Request $request, string $provider): ResponseFactory|Response
{
// Get the request data
$postData = $request->getContent();
$data = json_decode($postData, true);
try {
Log::info("Processing {$provider} webhook", [
'provider' => $provider,
'headers' => $request->headers->all(),
]);
// Validate request data
if (! $data || ! isset($data['type']) || ! in_array($data['type'], ['invoice', 'payment_link', 'payout'])) {
Log::warning('Invalid Oxapay webhook data', ['data' => $data]);
$result = $this->orchestrator->processWebhook($provider, $request);
return response('Invalid data.type', 400);
}
// Process Phase 4 specific events
$this->processPhase4Events($provider, $result);
// Determine API secret key based on type
$apiSecretKey = $data['type'] === 'invoice'
? config('services.oxapay.merchant_api_key')
: config('services.oxapay.payout_api_key');
// Validate HMAC signature
$hmacHeader = $request->header('HMAC');
$calculatedHmac = hash_hmac('sha512', $postData, (string) $apiSecretKey);
if (hash_equals($calculatedHmac, $hmacHeader)) {
// HMAC signature is valid
try {
if ($data['type'] === 'invoice' || $data['type'] === 'payment_link') {
// Process invoice payment data
$email = $data['email'] ?? 'Unknown';
$amount = $data['amount'] ?? 'Unknown';
$currency = $data['currency'] ?? 'Unknown';
$trackId = $data['track_id'] ?? 'Unknown';
$orderId = $data['order_id'] ?? 'N/A';
$date = isset($data['date']) ? Date::createFromTimestamp($data['date'])->toDateTimeString() : now()->toDateTimeString();
Log::info('Received Oxapay invoice payment callback', [
'track_id' => $trackId,
'email' => $email,
'amount' => $amount,
'currency' => $currency,
'order_id' => $orderId,
'date' => $date,
]);
$message = "✅ Oxapay Invoice Payment Success\n".
"Track ID: {$trackId}\n".
"Email: {$email}\n".
"Amount: {$amount} {$currency}\n".
"Order ID: {$orderId}\n".
"Time: {$date}";
self::sendTelegramNotification($message);
} elseif ($data['type'] === 'payout') {
// Process payout data
$trackId = $data['track_id'] ?? 'Unknown';
$amount = $data['amount'] ?? 'Unknown';
$currency = $data['currency'] ?? 'Unknown';
$network = $data['network'] ?? 'Unknown';
$address = $data['address'] ?? 'Unknown';
$txHash = $data['tx_hash'] ?? 'Unknown';
$description = $data['description'] ?? 'N/A';
$date = isset($data['date']) ? Date::createFromTimestamp($data['date'])->toDateTimeString() : now()->toDateTimeString();
Log::info('Received Oxapay payout callback', [
'track_id' => $trackId,
'status' => $data['status'] ?? 'Unknown',
'amount' => $amount,
'currency' => $currency,
'network' => $network,
'address' => $address,
'tx_hash' => $txHash,
'description' => $description,
'date' => $date,
]);
$message = "📤 Oxapay Payout Confirmed\n".
"Track ID: {$trackId}\n".
"Amount: {$amount} {$currency}\n".
"Network: {$network}\n".
"Address: {$address}\n".
"Transaction Hash: {$txHash}\n".
"Description: {$description}\n".
"Date: {$date}";
self::sendTelegramNotification($message);
}
return response('OK', 200);
} catch (Exception $e) {
Log::error('Oxapay webhook processing error', ['error' => $e->getMessage(), 'data' => $data]);
self::sendTelegramNotification("
Failed to process Oxapay webhook\n
Type: {$data['type']}\n
Email/Track ID: ".($data['type'] === 'invoice' ? ($data['email'] ?? 'Unknown') : ($data['track_id'] ?? 'Unknown'))."\n
Error: {$e->getMessage()}
");
return response('Processing error', 400);
// Send notification for successful payments
if ($this->isSuccessfulPayment($result)) {
$this->sendPaymentNotification($provider, $result);
}
} else {
Log::warning('Invalid Oxapay HMAC signature', ['hmac_header' => $hmacHeader, 'calculated_hmac' => $calculatedHmac]);
return response('Invalid HMAC signature', 400);
// Send notifications for Phase 4 events
$this->sendPhase4Notifications($provider, $result);
return response('OK', 200);
} catch (Exception $e) {
Log::error("{$provider} webhook processing error", [
'provider' => $provider,
'error' => $e->getMessage(),
'request_data' => $request->getContent(),
]);
$this->sendErrorNotification($provider, $e);
return response('Processing error', 400);
}
}
/**
* Legacy Oxapay webhook handler (for backward compatibility)
*/
public function oxapay(Request $request): ResponseFactory|Response
{
return $this->handle($request, 'oxapay');
}
/**
* Stripe webhook handler
*/
public function stripe(Request $request): ResponseFactory|Response
{
return $this->handle($request, 'stripe');
}
/**
* Lemon Squeezy webhook handler
*/
public function lemonSqueezy(Request $request): ResponseFactory|Response
{
return $this->handle($request, 'lemon_squeezy');
}
/**
* Polar webhook handler
*/
public function polar(Request $request): ResponseFactory|Response
{
return $this->handle($request, 'polar');
}
/**
* Crypto webhook handler
*/
public function crypto(Request $request): ResponseFactory|Response
{
return $this->handle($request, 'crypto');
}
/**
* Check if webhook result indicates a successful payment
*/
protected function isSuccessfulPayment(array $result): bool
{
if (! ($result['success'] ?? false)) {
return false;
}
$eventType = $result['event_type'] ?? '';
$status = $result['status'] ?? '';
// Check for successful payment events
$successfulEvents = [
'payment.succeeded',
'invoice.payment_succeeded',
'checkout.session.completed',
'subscription.created',
'subscription.updated',
'customer.subscription.created',
'charge.succeeded',
'payment_intent.succeeded',
'invoicepaid',
'Paid', // OxaPay status
];
return in_array($eventType, $successfulEvents) ||
in_array($status, ['paid', 'succeeded', 'completed', 'active']);
}
/**
* Send notification for successful payment
*/
protected function sendPaymentNotification(string $provider, array $result): void
{
$eventType = $result['event_type'] ?? $result['status'] ?? 'unknown';
$subscriptionId = $result['subscription_id'] ?? null;
$amount = $result['amount'] ?? 'Unknown';
$currency = $result['currency'] ?? 'Unknown';
$email = $result['email'] ?? 'Unknown';
$message = "{$this->getProviderDisplayName($provider)} Payment Success\n".
"Event: {$eventType}\n".
"Amount: {$amount} {$currency}\n".
($subscriptionId ? "Subscription ID: {$subscriptionId}\n" : '').
($email !== 'Unknown' ? "Email: {$email}\n" : '').
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Send error notification
*/
protected function sendErrorNotification(string $provider, Exception $e): void
{
$message = "{$this->getProviderDisplayName($provider)} Webhook Error\n".
"Error: {$e->getMessage()}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Process Phase 4 specific events
*/
protected function processPhase4Events(string $provider, array $result): void
{
$eventType = $result['event_type'] ?? '';
$subscriptionId = $result['subscription_id'] ?? null;
if (! $subscriptionId) {
return;
}
$subscription = Subscription::find($subscriptionId);
if (! $subscription) {
Log::warning('Subscription not found for Phase 4 processing', [
'subscription_id' => $subscriptionId,
'provider' => $provider,
]);
return;
}
// Handle coupon usage events
if ($this->isCouponUsageEvent($result)) {
$this->processCouponUsage($subscription, $result);
}
// Handle trial events
if ($this->isTrialEvent($result)) {
$this->processTrialEvent($subscription, $result);
}
// Handle subscription change events
if ($this->isSubscriptionChangeEvent($result)) {
$this->processSubscriptionChangeEvent($subscription, $result);
}
// Handle migration events
if ($this->isMigrationEvent($result)) {
$this->processMigrationEvent($subscription, $result);
}
}
/**
* Process coupon usage
*/
protected function processCouponUsage(Subscription $subscription, array $result): void
{
try {
$couponCode = $result['coupon_code'] ?? null;
$discountAmount = $result['discount_amount'] ?? 0;
if (! $couponCode) {
return;
}
$coupon = Coupon::where('code', $couponCode)->first();
if (! $coupon) {
Log::warning('Coupon not found', ['coupon_code' => $couponCode]);
return;
}
// Apply coupon to subscription if not already applied
$existingUsage = $subscription->couponUsages()
->where('coupon_id', $coupon->id)
->first();
if (! $existingUsage) {
$subscription->applyCoupon($coupon, $discountAmount);
Log::info('Coupon applied via webhook', [
'subscription_id' => $subscription->id,
'coupon_id' => $coupon->id,
'discount_amount' => $discountAmount,
]);
}
} catch (Exception $e) {
Log::error('Failed to process coupon usage', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Process trial events
*/
protected function processTrialEvent(Subscription $subscription, array $result): void
{
try {
$eventType = $result['event_type'] ?? '';
$newTrialEnd = $result['trial_ends_at'] ?? null;
if (! $newTrialEnd) {
return;
}
switch ($eventType) {
case 'trial.will_end':
case 'trial.ending':
// Send reminder notification
$this->sendTrialEndingNotification($subscription);
break;
case 'trial.extended':
// Record trial extension
$daysExtended = $result['trial_extension_days'] ?? 7;
$reason = $result['extension_reason'] ?? 'Extended by provider';
$subscription->extendTrial($daysExtended, $reason, 'automatic');
break;
case 'trial.ended':
// Record trial completion
$this->recordTrialCompletion($subscription, $result);
break;
}
} catch (Exception $e) {
Log::error('Failed to process trial event', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Process subscription change events
*/
protected function processSubscriptionChangeEvent(Subscription $subscription, array $result): void
{
try {
$eventType = $result['event_type'] ?? '';
$oldPlanId = $result['old_plan_id'] ?? null;
$newPlanId = $result['new_plan_id'] ?? null;
switch ($eventType) {
case 'plan.changed':
case 'subscription.updated':
if ($oldPlanId && $newPlanId) {
$changeType = $this->determinePlanChangeType($oldPlanId, $newPlanId);
$this->orchestrator->recordSubscriptionChange(
$subscription,
$changeType,
"Plan changed from {$oldPlanId} to {$newPlanId}",
['plan_id' => $oldPlanId],
['plan_id' => $newPlanId],
'Plan change via webhook'
);
}
break;
case 'subscription.paused':
$this->orchestrator->recordSubscriptionChange(
$subscription,
'pause',
'Subscription paused via webhook',
null,
['status' => 'paused'],
'Paused by provider'
);
break;
case 'subscription.resumed':
$this->orchestrator->recordSubscriptionChange(
$subscription,
'resume',
'Subscription resumed via webhook',
['status' => 'paused'],
['status' => 'active'],
'Resumed by provider'
);
break;
case 'subscription.cancelled':
$reason = $result['cancellation_reason'] ?? 'Cancelled by provider';
$this->orchestrator->recordSubscriptionChange(
$subscription,
'cancel',
'Subscription cancelled via webhook',
null,
['status' => 'cancelled', 'reason' => $reason],
$reason
);
break;
}
} catch (Exception $e) {
Log::error('Failed to process subscription change event', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Process migration events
*/
protected function processMigrationEvent(Subscription $subscription, array $result): void
{
try {
$targetProvider = $result['target_provider'] ?? null;
$migrationBatchId = $result['migration_batch_id'] ?? null;
if (! $targetProvider || ! $migrationBatchId) {
return;
}
$subscription->update([
'migration_batch_id' => $migrationBatchId,
'is_migrated' => true,
'legacy_data' => array_merge($subscription->legacy_data ?? [], [
'migration_source' => $result['source_provider'] ?? $subscription->provider,
'migration_date' => now()->toDateTimeString(),
'migration_reason' => $result['migration_reason'] ?? 'Provider migration',
]),
]);
Log::info('Subscription migration recorded', [
'subscription_id' => $subscription->id,
'migration_batch_id' => $migrationBatchId,
'target_provider' => $targetProvider,
]);
} catch (Exception $e) {
Log::error('Failed to process migration event', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Send Phase 4 specific notifications
*/
protected function sendPhase4Notifications(string $provider, array $result): void
{
$eventType = $result['event_type'] ?? '';
switch ($eventType) {
case 'coupon.applied':
$this->sendCouponAppliedNotification($provider, $result);
break;
case 'trial.ending':
$this->sendTrialEndingNotification($result);
break;
case 'trial.extended':
$this->sendTrialExtendedNotification($provider, $result);
break;
case 'plan.changed':
$this->sendPlanChangedNotification($provider, $result);
break;
case 'subscription.migrated':
$this->sendMigrationNotification($provider, $result);
break;
}
}
/**
* Send coupon applied notification
*/
protected function sendCouponAppliedNotification(string $provider, array $result): void
{
$couponCode = $result['coupon_code'] ?? 'Unknown';
$discountAmount = $result['discount_amount'] ?? 0;
$email = $result['email'] ?? 'Unknown';
$message = "🎫 Coupon Applied\n".
"Provider: {$this->getProviderDisplayName($provider)}\n".
"Coupon: {$couponCode}\n".
"Discount: {$discountAmount}\n".
"Email: {$email}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Send trial ending notification
*/
protected function sendTrialEndingNotification($subscriptionOrResult): void
{
if ($subscriptionOrResult instanceof Subscription) {
$subscription = $subscriptionOrResult;
$email = $subscription->user?->email ?? 'Unknown';
$trialEndsAt = $subscription->trial_ends_at?->toDateTimeString() ?? 'Unknown';
} else {
$email = $subscriptionOrResult['email'] ?? 'Unknown';
$trialEndsAt = $subscriptionOrResult['trial_ends_at'] ?? 'Unknown';
}
$message = "⏰ Trial Ending Soon\n".
"Email: {$email}\n".
"Trial ends: {$trialEndsAt}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Send trial extended notification
*/
protected function sendTrialExtendedNotification(string $provider, array $result): void
{
$daysExtended = $result['trial_extension_days'] ?? 0;
$newTrialEnd = $result['new_trial_ends_at'] ?? 'Unknown';
$reason = $result['extension_reason'] ?? 'Extended';
$message = "✅ Trial Extended\n".
"Provider: {$this->getProviderDisplayName($provider)}\n".
"Days extended: {$daysExtended}\n".
"New trial end: {$newTrialEnd}\n".
"Reason: {$reason}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Send plan changed notification
*/
protected function sendPlanChangedNotification(string $provider, array $result): void
{
$oldPlan = $result['old_plan_name'] ?? 'Unknown';
$newPlan = $result['new_plan_name'] ?? 'Unknown';
$email = $result['email'] ?? 'Unknown';
$message = "🔄 Plan Changed\n".
"Provider: {$this->getProviderDisplayName($provider)}\n".
"Email: {$email}\n".
"Old plan: {$oldPlan}\n".
"New plan: {$newPlan}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Send migration notification
*/
protected function sendMigrationNotification(string $provider, array $result): void
{
$sourceProvider = $result['source_provider'] ?? 'Unknown';
$targetProvider = $result['target_provider'] ?? 'Unknown';
$migrationBatchId = $result['migration_batch_id'] ?? 'Unknown';
$message = "🔄 Subscription Migration\n".
"Source: {$this->getProviderDisplayName($sourceProvider)}\n".
"Target: {$this->getProviderDisplayName($targetProvider)}\n".
"Batch ID: {$migrationBatchId}\n".
'Time: '.now()->toDateTimeString();
$this->sendTelegramNotification($message);
}
/**
* Record trial completion
*/
protected function recordTrialCompletion(Subscription $subscription, array $result): void
{
$this->orchestrator->recordSubscriptionChange(
$subscription,
'trial_completed',
'Trial period completed',
['status' => 'trialing'],
['status' => $subscription->status],
'Trial ended naturally'
);
}
/**
* Check if event is a coupon usage event
*/
protected function isCouponUsageEvent(array $result): bool
{
$eventType = $result['event_type'] ?? '';
return in_array($eventType, [
'coupon.applied',
'discount.applied',
'coupon.redeemed',
]) || isset($result['coupon_code']);
}
/**
* Check if event is a trial event
*/
protected function isTrialEvent(array $result): bool
{
$eventType = $result['event_type'] ?? '';
return in_array($eventType, [
'trial.started',
'trial.will_end',
'trial.ending',
'trial.ended',
'trial.extended',
]) || isset($result['trial_ends_at']);
}
/**
* Check if event is a subscription change event
*/
protected function isSubscriptionChangeEvent(array $result): bool
{
$eventType = $result['event_type'] ?? '';
return in_array($eventType, [
'plan.changed',
'subscription.updated',
'subscription.paused',
'subscription.resumed',
'subscription.cancelled',
]) || isset($result['new_plan_id']);
}
/**
* Check if event is a migration event
*/
protected function isMigrationEvent(array $result): bool
{
$eventType = $result['event_type'] ?? '';
return in_array($eventType, [
'subscription.migrated',
'provider.migrated',
]) || isset($result['migration_batch_id']);
}
/**
* Determine plan change type
*/
protected function determinePlanChangeType(?int $oldPlanId, ?int $newPlanId): string
{
if (! $oldPlanId || ! $newPlanId) {
return 'plan_change';
}
// This is a simplified determination - in practice you'd compare plan prices/features
return $newPlanId > $oldPlanId ? 'plan_upgrade' : 'plan_downgrade';
}
/**
* Get display name for provider
*/
protected function getProviderDisplayName(string $provider): string
{
$displayNames = [
'stripe' => 'Stripe',
'lemon_squeezy' => 'Lemon Squeezy',
'polar' => 'Polar.sh',
'oxapay' => 'OxaPay',
'crypto' => 'Crypto',
'activation_key' => 'Activation Key',
];
return $displayNames[$provider] ?? ucfirst($provider);
}
}

255
app/Models/Coupon.php Normal file
View File

@@ -0,0 +1,255 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Coupon extends Model
{
protected $fillable = [
'code',
'name',
'description',
'type',
'value',
'minimum_amount',
'max_uses',
'uses_count',
'max_uses_per_user',
'starts_at',
'expires_at',
'is_active',
'metadata',
];
protected $casts = [
'minimum_amount' => 'decimal:2',
'value' => 'decimal:2',
'starts_at' => 'datetime',
'expires_at' => 'datetime',
'is_active' => 'boolean',
'metadata' => 'array',
];
protected $dates = [
'starts_at',
'expires_at',
];
/**
* Relationships
*/
public function usages()
{
return $this->hasMany(CouponUsage::class);
}
/**
* Check if coupon is currently valid
*/
public function isValid(?User $user = null): bool
{
// Check if coupon is active
if (! $this->is_active) {
return false;
}
// Check start date
if ($this->starts_at && $this->starts_at->isFuture()) {
return false;
}
// Check expiration date
if ($this->expires_at && $this->expires_at->isPast()) {
return false;
}
// Check usage limits
if ($this->max_uses && $this->uses_count >= $this->max_uses) {
return false;
}
// Check per-user usage limits
if ($user && $this->max_uses_per_user) {
$userUsageCount = $this->usages()->where('user_id', $user->id)->count();
if ($userUsageCount >= $this->max_uses_per_user) {
return false;
}
}
return true;
}
/**
* Calculate discount amount for a given subtotal
*/
public function calculateDiscount(float $subtotal): float
{
// Check minimum amount requirement
if ($this->minimum_amount && $subtotal < $this->minimum_amount) {
return 0;
}
if ($this->type === 'percentage') {
return $subtotal * ($this->value / 100);
}
return min($this->value, $subtotal);
}
/**
* Apply coupon to a subscription
*/
public function applyToSubscription(Subscription $subscription, float $amount, string $currency = 'USD'): CouponUsage
{
$discountAmount = $this->calculateDiscount($amount);
$usage = $this->usages()->create([
'user_id' => $subscription->user_id,
'subscription_id' => $subscription->id,
'discount_amount' => $discountAmount,
'currency' => $currency,
'used_at' => now(),
]);
// Increment usage count
$this->increment('uses_count');
return $usage;
}
/**
* Get formatted discount value
*/
public function getFormattedDiscountAttribute(): string
{
if ($this->type === 'percentage') {
return $this->value.'%';
}
return '$'.number_format($this->value, 2);
}
/**
* Get remaining uses
*/
public function getRemainingUsesAttribute(): ?int
{
if (! $this->max_uses) {
return null;
}
return max(0, $this->max_uses - $this->uses_count);
}
/**
* Get remaining uses for specific user
*/
public function getRemainingUsesForUser(User $user): ?int
{
if (! $this->max_uses_per_user) {
return null;
}
$userUsageCount = $this->usages()->where('user_id', $user->id)->count();
return max(0, $this->max_uses_per_user - $userUsageCount);
}
/**
* Check if coupon is expiring soon (within 7 days)
*/
public function isExpiringSoon(): bool
{
return $this->expires_at &&
$this->expires_at->copy()->subDays(7)->isPast() &&
$this->expires_at->isFuture();
}
/**
* Scope: Active coupons
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* Scope: Valid for use right now
*/
public function scopeValid(Builder $query, ?User $user = null): Builder
{
$query->where('is_active', true)
->where(function ($q) {
$q->whereNull('starts_at')
->orWhere('starts_at', '<=', now());
})
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
// Check global usage limits
$query->where(function ($q) {
$q->whereNull('max_uses')
->orWhereRaw('uses_count < max_uses');
});
return $query;
}
/**
* Scope: By type
*/
public function scopeByType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
/**
* Scope: Expiring soon
*/
public function scopeExpiringSoon(Builder $query, int $days = 7): Builder
{
return $query->where('expires_at', '<=', now()->addDays($days))
->where('expires_at', '>', now());
}
/**
* Scope: Search by code or name
*/
public function scopeSearch(Builder $query, string $term): Builder
{
return $query->where(function ($q) use ($term) {
$q->where('code', 'like', "%{$term}%")
->orWhere('name', 'like', "%{$term}%");
});
}
/**
* Find coupon by code
*/
public static function findByCode(string $code): ?self
{
return static::where('code', strtoupper($code))->first();
}
/**
* Boot: Automatically uppercase coupon codes
*/
protected static function boot()
{
parent::boot();
static::creating(function ($coupon) {
$coupon->code = strtoupper($coupon->code);
});
static::updating(function ($coupon) {
if ($coupon->isDirty('code')) {
$coupon->code = strtoupper($coupon->code);
}
});
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CouponUsage extends Model
{
protected $fillable = [
'coupon_id',
'user_id',
'subscription_id',
'discount_amount',
'currency',
'used_at',
'metadata',
];
protected $casts = [
'discount_amount' => 'decimal:2',
'used_at' => 'datetime',
'metadata' => 'array',
];
protected $dates = [
'used_at',
];
/**
* Relationships
*/
public function coupon()
{
return $this->belongsTo(Coupon::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function subscription()
{
return $this->belongsTo(Subscription::class);
}
/**
* Get formatted discount amount
*/
public function getFormattedDiscountAttribute(): string
{
return $this->currency.' '.number_format($this->discount_amount, 2);
}
/**
* Scope: By user
*/
public function scopeByUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope: By coupon
*/
public function scopeByCoupon($query, $couponId)
{
return $query->where('coupon_id', $couponId);
}
/**
* Scope: Within date range
*/
public function scopeBetweenDates($query, $startDate, $endDate)
{
return $query->whereBetween('used_at', [$startDate, $endDate]);
}
}

304
app/Models/PaymentEvent.php Normal file
View File

@@ -0,0 +1,304 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class PaymentEvent extends Model
{
protected $fillable = [
'event_type',
'level',
'data',
'user_type',
'user_id',
'request_id',
'ip_address',
'user_agent',
'expires_at',
'provider',
'webhook_event_type',
'payload',
'success',
'stored_at',
];
protected $casts = [
'data' => 'array',
'expires_at' => 'datetime',
];
/**
* Scope to get only security events
*/
public function scopeSecurity(Builder $query): Builder
{
return $query->where('event_type', 'like', 'security_%');
}
/**
* Scope to get only compliance events
*/
public function scopeCompliance(Builder $query): Builder
{
return $query->where('event_type', 'like', 'compliance_%');
}
/**
* Scope to get only webhook events
*/
public function scopeWebhooks(Builder $query): Builder
{
return $query->where('event_type', 'like', 'webhook_%');
}
/**
* Scope to get only error events
*/
public function scopeErrors(Builder $query): Builder
{
return $query->where('level', 'error');
}
/**
* Scope to get events for a specific provider
*/
public function scopeForProvider(Builder $query, string $provider): Builder
{
return $query->whereJsonContains('data->provider', $provider);
}
/**
* Scope to get events for a specific subscription
*/
public function scopeForSubscription(Builder $query, int $subscriptionId): Builder
{
return $query->whereJsonContains('data->subscription_id', $subscriptionId);
}
/**
* Scope to get events that require review
*/
public function scopeRequiresReview(Builder $query): Builder
{
return $query->whereJsonContains('data->requires_review', true);
}
/**
* Scope to get events that haven't expired
*/
public function scopeNotExpired(Builder $query): Builder
{
return $query->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
/**
* Scope to get expired events (for cleanup)
*/
public function scopeExpired(Builder $query): Builder
{
return $query->where('expires_at', '<', now());
}
/**
* Get the user relationship
*/
public function user()
{
return $this->morphTo();
}
/**
* Check if event is security-related
*/
public function isSecurityEvent(): bool
{
return str_starts_with($this->event_type, 'security_');
}
/**
* Check if event is compliance-related
*/
public function isComplianceEvent(): bool
{
return str_starts_with($this->event_type, 'compliance_');
}
/**
* Check if event is webhook-related
*/
public function isWebhookEvent(): bool
{
return str_starts_with($this->event_type, 'webhook_');
}
/**
* Check if event requires review
*/
public function requiresReview(): bool
{
return ($this->data['requires_review'] ?? false) ||
$this->isSecurityEvent() ||
$this->level === 'error';
}
/**
* Get the provider from event data
*/
public function getProvider(): ?string
{
return $this->data['provider'] ?? null;
}
/**
* Get the subscription ID from event data
*/
public function getSubscriptionId(): ?int
{
return $this->data['subscription_id'] ?? null;
}
/**
* Get the action from event data
*/
public function getAction(): ?string
{
return $this->data['action'] ?? null;
}
/**
* Check if event contains sensitive data
*/
public function containsSensitiveData(): bool
{
$sensitiveKeys = ['payment_method', 'card_number', 'bank_account', 'ssn', 'full_credit_card'];
foreach ($sensitiveKeys as $key) {
if (isset($this->data[$key])) {
return true;
}
}
return false;
}
/**
* Get sanitized data for display (removes sensitive information)
*/
public function getSanitizedData(): array
{
$data = $this->data;
// Remove or mask sensitive fields
$sensitivePatterns = [
'/payment_method.*?number/i',
'/card_?number/i',
'/cvv/i',
'/cvc/i',
'/ssn/i',
'/bank_?account/i',
'/routing_?number/i',
];
foreach ($sensitivePatterns as $pattern) {
$data = array_map(function ($value) use ($pattern) {
if (is_string($value) && preg_match($pattern, $value)) {
return str_repeat('*', strlen($value) - 4).substr($value, -4);
}
return $value;
}, $data);
}
return $data;
}
/**
* Export event for compliance reporting
*/
public function toComplianceArray(): array
{
return [
'id' => $this->id,
'event_type' => $this->event_type,
'level' => $this->level,
'created_at' => $this->created_at->toISOString(),
'user_id' => $this->user_id,
'user_type' => $this->user_type,
'request_id' => $this->request_id,
'ip_address' => $this->ip_address,
'provider' => $this->getProvider(),
'subscription_id' => $this->getSubscriptionId(),
'action' => $this->getAction(),
'requires_review' => $this->requiresReview(),
'is_security_event' => $this->isSecurityEvent(),
'is_compliance_event' => $this->isComplianceEvent(),
'contains_sensitive_data' => $this->containsSensitiveData(),
];
}
/**
* Clean up old events based on retention policy
*/
public static function cleanup(): array
{
$results = [];
// Clean up expired webhook payloads
$webhookCleanup = static::webhooks()
->expired()
->delete();
$results['webhook_payloads'] = $webhookCleanup;
// Clean up old debug events
$debugCleanup = static::where('level', 'debug')
->where('created_at', '<', now()->subDays(90))
->delete();
$results['debug_events'] = $debugCleanup;
// Clean up old info events (keep for 1 year)
$infoCleanup = static::where('level', 'info')
->where('created_at', '<', now()->subYear())
->whereNot(function ($query) {
$query->security()
->compliance();
})
->delete();
$results['info_events'] = $infoCleanup;
return $results;
}
/**
* Get events by date range for reporting
*/
public static function getByDateRange(\DateTime $start, \DateTime $end, array $filters = []): Builder
{
$query = static::whereBetween('created_at', [$start, $end]);
if (! empty($filters['event_types'])) {
$query->whereIn('event_type', $filters['event_types']);
}
if (! empty($filters['levels'])) {
$query->whereIn('level', $filters['levels']);
}
if (! empty($filters['user_id'])) {
$query->where('user_id', $filters['user_id']);
}
if (! empty($filters['provider'])) {
$query->forProvider($filters['provider']);
}
return $query;
}
}

View File

@@ -0,0 +1,301 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentProvider extends Model
{
protected $fillable = [
'name',
'display_name',
'description',
'is_active',
'configuration',
'supports_recurring',
'supports_one_time',
'supported_currencies',
'webhook_url',
'webhook_secret',
'fee_structure',
'priority',
'is_fallback',
];
protected $casts = [
'is_active' => 'boolean',
'configuration' => 'array',
'supports_recurring' => 'boolean',
'supports_one_time' => 'boolean',
'supported_currencies' => 'array',
'fee_structure' => 'array',
'is_fallback' => 'boolean',
];
/**
* Scope to get only active providers
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope to get providers that support recurring payments
*/
public function scopeRecurring($query)
{
return $query->where('supports_recurring', true);
}
/**
* Scope to get providers that support one-time payments
*/
public function scopeOneTime($query)
{
return $query->where('supports_one_time', true);
}
/**
* Scope to get providers ordered by priority
*/
public function scopeByPriority($query)
{
return $query->orderBy('priority', 'desc');
}
/**
* Get the fallback provider
*/
public static function getFallback()
{
return static::where('is_fallback', true)->active()->first();
}
/**
* Check if provider supports a specific currency
*/
public function supportsCurrency(string $currency): bool
{
return in_array(strtoupper($currency), $this->supported_currencies);
}
/**
* Get fee for a specific amount
*/
public function calculateFee(float $amount): array
{
if (! $this->fee_structure) {
return [
'fixed_fee' => 0,
'percentage_fee' => 0,
'total_fee' => 0,
'net_amount' => $amount,
];
}
$fixedFee = $this->fee_structure['fixed_fee'] ?? 0;
$percentageFee = $this->fee_structure['percentage_fee'] ?? 0;
$percentageAmount = ($amount * $percentageFee) / 100;
$totalFee = $fixedFee + $percentageAmount;
return [
'fixed_fee' => $fixedFee,
'percentage_fee' => $percentageAmount,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
/**
* Get webhook endpoint URL
*/
public function getWebhookUrl(): string
{
return $this->webhook_url ?? route('webhook.payment', $this->name);
}
/**
* Update configuration
*/
public function updateConfiguration(array $config): void
{
$this->configuration = array_merge($this->configuration ?? [], $config);
$this->save();
}
/**
* Get specific configuration value
*/
public function getConfigValue(string $key, $default = null)
{
return data_get($this->configuration, $key, $default);
}
/**
* Set specific configuration value
*/
public function setConfigValue(string $key, $value): void
{
$config = $this->configuration ?? [];
data_set($config, $key, $value);
$this->configuration = $config;
$this->save();
}
/**
* Check if provider is properly configured
*/
public function isConfigured(): bool
{
$requiredFields = $this->getConfigValue('required_fields', []);
foreach ($requiredFields as $field) {
if (empty($this->getConfigValue($field))) {
return false;
}
}
return true;
}
/**
* Get provider statistics
*/
public function getStats(): array
{
$subscriptionCount = Subscription::where('provider', $this->name)->count();
$activeSubscriptionCount = Subscription::where('provider', $this->name)
->where('unified_status', 'active')
->count();
return [
'name' => $this->name,
'display_name' => $this->display_name,
'is_active' => $this->is_active,
'is_configured' => $this->isConfigured(),
'total_subscriptions' => $subscriptionCount,
'active_subscriptions' => $activeSubscriptionCount,
'supports_recurring' => $this->supports_recurring,
'supports_one_time' => $this->supports_one_time,
'supported_currencies' => $this->supported_currencies,
'is_fallback' => $this->is_fallback,
'priority' => $this->priority,
];
}
/**
* Activate provider
*/
public function activate(): void
{
$this->is_active = true;
$this->save();
}
/**
* Deactivate provider
*/
public function deactivate(): void
{
if ($this->is_fallback) {
throw new \Exception('Cannot deactivate fallback provider');
}
$this->is_active = false;
$this->save();
}
/**
* Set as fallback provider
*/
public function setAsFallback(): void
{
// Remove fallback status from other providers
static::where('is_fallback', true)->update(['is_fallback' => false]);
$this->is_fallback = true;
$this->save();
}
/**
* Remove fallback status
*/
public function removeFallback(): void
{
$this->is_fallback = false;
$this->save();
}
/**
* Get provider class name
*/
public function getProviderClass(): string
{
return $this->getConfigValue('class', '');
}
/**
* Test provider connection
*/
public function testConnection(): array
{
try {
$class = $this->getProviderClass();
if (! class_exists($class)) {
return [
'success' => false,
'error' => "Provider class {$class} not found",
];
}
$provider = new $class($this->configuration);
if (! $provider instanceof \App\Contracts\Payments\PaymentProviderContract) {
return [
'success' => false,
'error' => 'Provider class does not implement PaymentProviderContract',
];
}
$isActive = $provider->isActive();
return [
'success' => true,
'is_active' => $isActive,
'configuration_valid' => $this->isConfigured(),
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Get all active providers ordered by priority
*/
public static function getActiveOrdered()
{
return static::active()->byPriority()->get();
}
/**
* Get providers that support a specific plan type
*/
public static function getForPlanType(bool $recurring = false)
{
$query = static::active();
if ($recurring) {
$query->recurring();
} else {
$query->oneTime();
}
return $query->byPriority()->get();
}
}

475
app/Models/Subscription.php Normal file
View File

@@ -0,0 +1,475 @@
<?php
namespace App\Models;
use App\Services\Payments\PaymentOrchestrator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
class Subscription extends Model
{
protected $fillable = [
'user_id',
'type',
'stripe_id',
'stripe_status',
'stripe_price',
'quantity',
'trial_ends_at',
'ends_at',
'provider',
'provider_subscription_id',
'unified_status',
'cancelled_at',
'cancellation_reason',
'paused_at',
'resumed_at',
'migration_batch_id',
'is_migrated',
'legacy_data',
'synced_at',
'provider_data',
'last_provider_sync',
'plan_id',
'starts_at',
'status',
'updated_at',
'polar_subscription',
'metadata',
'migration_source',
'migration_date',
'migration_reason',
];
protected $casts = [
'is_migrated' => 'boolean',
'legacy_data' => 'array',
'provider_data' => 'array',
'metadata' => 'array',
'trial_ends_at' => 'datetime',
'ends_at' => 'datetime',
'cancelled_at' => 'datetime',
'paused_at' => 'datetime',
'resumed_at' => 'datetime',
'synced_at' => 'datetime',
'last_provider_sync' => 'datetime',
'starts_at' => 'datetime',
];
/**
* Accessor for total coupon discount
*/
protected function getTotalCouponDiscountAttribute(): float
{
// Use preloaded sum if available, otherwise calculate it
if (array_key_exists('total_coupon_discount', $this->attributes)) {
return (float) $this->attributes['total_coupon_discount'];
}
return $this->couponUsages()->sum('discount_amount');
}
protected $dates = [
'trial_ends_at',
'ends_at',
'cancelled_at',
'paused_at',
'resumed_at',
'synced_at',
'last_provider_sync',
'starts_at',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function plan()
{
return $this->belongsTo(Plan::class);
}
public function trialExtensions()
{
return $this->hasMany(TrialExtension::class);
}
public function subscriptionChanges()
{
return $this->hasMany(SubscriptionChange::class);
}
public function couponUsages()
{
return $this->hasMany(CouponUsage::class);
}
/**
* Check if subscription is active
*/
public function isActive(): bool
{
return in_array($this->status, ['active', 'trialing']) &&
(! $this->ends_at || $this->ends_at->isFuture());
}
/**
* Check if subscription is on trial
*/
public function isOnTrial(): bool
{
return $this->status === 'trialing' &&
$this->trial_ends_at &&
$this->trial_ends_at->isFuture();
}
/**
* Check if subscription is cancelled
*/
public function isCancelled(): bool
{
return $this->status === 'cancelled' ||
($this->ends_at && $this->ends_at->isPast());
}
/**
* Check if subscription supports recurring payments
*/
public function isRecurring(): bool
{
return $this->plan && $this->plan->monthly_billing;
}
/**
* Get the display name for the provider
*/
public function getProviderDisplayName(): string
{
$displayNames = [
'stripe' => 'Stripe',
'lemon_squeezy' => 'Lemon Squeezy',
'polar' => 'Polar.sh',
'oxapay' => 'OxaPay',
'crypto' => 'Crypto',
'activation_key' => 'Activation Key',
];
return $displayNames[$this->provider] ?? ucfirst($this->provider);
}
/**
* Get provider-specific data
*/
public function getProviderData(?string $key = null, $default = null)
{
if ($key) {
return data_get($this->provider_data, $key, $default);
}
return $this->provider_data;
}
/**
* Set provider-specific data
*/
public function setProviderData(string $key, $value): void
{
$data = $this->provider_data ?? [];
data_set($data, $key, $value);
$this->provider_data = $data;
}
/**
* Sync subscription status with provider
*/
public function syncWithProvider(): bool
{
try {
$orchestrator = app(PaymentOrchestrator::class);
$result = $orchestrator->syncSubscriptionStatus($this);
$this->update([
'status' => $result['status'] ?? $this->status,
'provider_data' => array_merge($this->provider_data ?? [], $result),
'last_provider_sync' => now(),
]);
Log::info('Subscription synced with provider', [
'subscription_id' => $this->id,
'provider' => $this->provider,
'status' => $result['status'] ?? 'unknown',
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to sync subscription with provider', [
'subscription_id' => $this->id,
'provider' => $this->provider,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Cancel the subscription
*/
public function cancel(string $reason = ''): bool
{
try {
$orchestrator = app(PaymentOrchestrator::class);
$result = $orchestrator->cancelSubscription($this, $reason);
if ($result) {
$this->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
}
return $result;
} catch (\Exception $e) {
Log::error('Failed to cancel subscription', [
'subscription_id' => $this->id,
'provider' => $this->provider,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Update subscription plan
*/
public function updatePlan(Plan $newPlan): bool
{
try {
$orchestrator = app(PaymentOrchestrator::class);
$result = $orchestrator->updateSubscription($this, $newPlan);
if ($result['success']) {
$this->update([
'plan_id' => $newPlan->id,
'updated_at' => now(),
]);
}
return $result['success'];
} catch (\Exception $e) {
Log::error('Failed to update subscription plan', [
'subscription_id' => $this->id,
'provider' => $this->provider,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get subscription metadata
*/
public function getMetadata(?string $key = null, $default = null)
{
if ($key) {
return data_get($this->metadata, $key, $default);
}
return $this->metadata;
}
/**
* Set subscription metadata
*/
public function setMetadata(string $key, $value): void
{
$data = $this->metadata ?? [];
data_set($data, $key, $value);
$this->metadata = $data;
}
/**
* Scope: Active subscriptions
*/
public function scopeActive($query)
{
return $query->where('status', 'active')
->where(function ($q) {
$q->whereNull('ends_at')->orWhere('ends_at', '>', now());
});
}
/**
* Scope: Cancelled subscriptions
*/
public function scopeCancelled($query)
{
return $query->where('status', 'cancelled')
->where(function ($q) {
$q->whereNull('ends_at')->orWhere('ends_at', '<=', now());
});
}
/**
* Scope: On trial subscriptions
*/
public function scopeOnTrial($query)
{
return $query->where('status', 'trialing')
->where('trial_ends_at', '>', now());
}
/**
* Scope: By provider
*/
public function scopeByProvider($query, string $provider)
{
return $query->where('provider', $provider);
}
/**
* Scope: By user
*/
public function scopeByUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope: With total coupon discount
*/
public function scopeWithTotalCouponDiscount($query)
{
return $query->withSum('couponUsages as total_coupon_discount', 'discount_amount');
}
/**
* Extend trial period
*/
public function extendTrial(int $days, string $reason = '', string $extensionType = 'manual', ?User $grantedBy = null): TrialExtension
{
$originalEnd = $this->trial_ends_at;
$newEnd = $originalEnd ? $originalEnd->copy()->addDays($days) : now()->addDays($days);
$extension = $this->trialExtensions()->create([
'user_id' => $this->user_id,
'extension_days' => $days,
'reason' => $reason,
'extension_type' => $extensionType,
'original_trial_ends_at' => $originalEnd,
'new_trial_ends_at' => $newEnd,
'granted_at' => now(),
'granted_by_admin_id' => $grantedBy?->id,
]);
// Update the subscription's trial end date
$this->update(['trial_ends_at' => $newEnd]);
// Record the change
SubscriptionChange::createRecord(
$this,
'pause',
"Trial extended by {$days} days",
['trial_ends_at' => $originalEnd?->format('Y-m-d H:i:s')],
['trial_ends_at' => $newEnd->format('Y-m-d H:i:s')],
$reason
);
return $extension;
}
/**
* Get total trial extensions granted
*/
public function getTotalTrialExtensionsDays(): int
{
return $this->trialExtensions()->sum('extension_days');
}
/**
* Get latest trial extension
*/
public function getLatestTrialExtension(): ?TrialExtension
{
return $this->trialExtensions()->latest()->first();
}
/**
* Check if trial was extended
*/
public function hasExtendedTrial(): bool
{
return $this->trialExtensions()->exists();
}
/**
* Apply coupon to subscription
*/
public function applyCoupon(Coupon $coupon, float $amount): CouponUsage
{
if (! $coupon->isValid($this->user)) {
throw new \Exception('Coupon is not valid for this user');
}
return $coupon->applyToSubscription($this, $amount);
}
/**
* Get total discount from coupons
*/
public function getTotalCouponDiscount(): float
{
return $this->couponUsages()->sum('discount_amount');
}
/**
* Record subscription change
*/
public function recordChange(
string $changeType,
string $description,
?array $oldValues = null,
?array $newValues = null,
?string $reason = null
): SubscriptionChange {
return SubscriptionChange::createRecord(
$this,
$changeType,
$description,
$oldValues,
$newValues,
$reason
);
}
/**
* Get pending changes
*/
public function getPendingChanges(): \Illuminate\Database\Eloquent\Collection
{
return $this->subscriptionChanges()->pending()->get();
}
/**
* Process pending changes
*/
public function processPendingChanges(): int
{
$pending = $this->getPendingChanges();
$processedCount = 0;
foreach ($pending as $change) {
$change->markAsProcessed();
$processedCount++;
}
return $processedCount;
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SubscriptionChange extends Model
{
protected $fillable = [
'subscription_id',
'user_id',
'change_type',
'change_description',
'old_values',
'new_values',
'reason',
'effective_at',
'processed_at',
'is_processed',
'metadata',
];
protected $casts = [
'old_values' => 'array',
'new_values' => 'array',
'effective_at' => 'datetime',
'processed_at' => 'datetime',
'is_processed' => 'boolean',
'metadata' => 'array',
];
protected $dates = [
'effective_at',
'processed_at',
];
/**
* Relationships
*/
public function subscription()
{
return $this->belongsTo(Subscription::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Get human-readable change type
*/
public function getChangeTypeLabelAttribute(): string
{
return [
'plan_change' => 'Plan Change',
'cancellation' => 'Cancellation',
'pause' => 'Pause',
'resume' => 'Resume',
'migration' => 'Migration',
'provider_change' => 'Provider Change',
][$this->change_type] ?? ucfirst($this->change_type);
}
/**
* Mark as processed
*/
public function markAsProcessed(): void
{
$this->update([
'is_processed' => true,
'processed_at' => now(),
]);
}
/**
* Scope: By change type
*/
public function scopeByType($query, string $type)
{
return $query->where('change_type', $type);
}
/**
* Scope: Processed
*/
public function scopeProcessed($query)
{
return $query->where('is_processed', true);
}
/**
* Scope: Pending processing
*/
public function scopePending($query)
{
return $query->where('is_processed', false);
}
/**
* Scope: By user
*/
public function scopeByUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope: Within date range
*/
public function scopeBetweenDates($query, $startDate, $endDate)
{
return $query->whereBetween('effective_at', [$startDate, $endDate]);
}
/**
* Create a subscription change record
*/
public static function createRecord(
Subscription $subscription,
string $changeType,
string $description,
?array $oldValues = null,
?array $newValues = null,
?string $reason = null
): self {
return static::create([
'subscription_id' => $subscription->id,
'user_id' => $subscription->user_id,
'change_type' => $changeType,
'change_description' => $description,
'old_values' => $oldValues,
'new_values' => $newValues,
'reason' => $reason,
'effective_at' => now(),
]);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TrialExtension extends Model
{
protected $fillable = [
'subscription_id',
'user_id',
'extension_days',
'reason',
'extension_type',
'original_trial_ends_at',
'new_trial_ends_at',
'granted_at',
'granted_by_admin_id',
'metadata',
];
protected $casts = [
'original_trial_ends_at' => 'datetime',
'new_trial_ends_at' => 'datetime',
'granted_at' => 'datetime',
'metadata' => 'array',
];
protected $dates = [
'original_trial_ends_at',
'new_trial_ends_at',
'granted_at',
];
/**
* Relationships
*/
public function subscription()
{
return $this->belongsTo(Subscription::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function grantedByAdmin()
{
return $this->belongsTo(User::class, 'granted_by_admin_id');
}
/**
* Get human-readable extension type
*/
public function getExtensionTypeLabelAttribute(): string
{
return [
'manual' => 'Manual Grant',
'automatic' => 'Automatic Extension',
'compensation' => 'Compensation',
][$this->extension_type] ?? ucfirst($this->extension_type);
}
/**
* Scope: By extension type
*/
public function scopeByType($query, string $type)
{
return $query->where('extension_type', $type);
}
/**
* Scope: By user
*/
public function scopeByUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope: Granted by admin
*/
public function scopeGrantedBy($query, $adminId)
{
return $query->where('granted_by_admin_id', $adminId);
}
/**
* Scope: Within date range
*/
public function scopeBetweenDates($query, $startDate, $endDate)
{
return $query->whereBetween('granted_at', [$startDate, $endDate]);
}
}

View File

@@ -149,4 +149,287 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
{
return $this->hasMany(ImpersonationLog::class, 'target_user_id');
}
/**
* Get all subscriptions for the user
*/
public function subscriptions()
{
return $this->hasMany(Subscription::class);
}
/**
* Get the current active subscription for the user
*/
public function currentSubscription()
{
return $this->hasOne(Subscription::class)
->where(function ($query) {
$query->where('status', 'active')
->orWhere('status', 'trialing');
})
->where(function ($query) {
$query->whereNull('ends_at')
->orWhere('ends_at', '>', now());
})
->latest();
}
/**
* Get the latest subscription (regardless of status)
*/
public function latestSubscription()
{
return $this->hasOne(Subscription::class)->latestOfMany();
}
/**
* Scope: Users with active subscriptions
*/
public function scopeWithActiveSubscription($query)
{
return $query->whereHas('subscriptions', function ($subscriptionQuery) {
$subscriptionQuery->where(function ($q) {
$q->where('status', 'active')
->orWhere('status', 'trialing');
})->where(function ($q) {
$q->whereNull('ends_at')
->orWhere('ends_at', '>', now());
});
});
}
/**
* Scope: Users with trial subscriptions
*/
public function scopeWithTrialSubscription($query)
{
return $query->whereHas('subscriptions', function ($subscriptionQuery) {
$subscriptionQuery->where('status', 'trialing')
->where('trial_ends_at', '>', now());
});
}
/**
* Scope: Users with cancelled subscriptions
*/
public function scopeWithCancelledSubscription($query)
{
return $query->whereHas('subscriptions', function ($subscriptionQuery) {
$subscriptionQuery->where('status', 'cancelled')
->orWhere(function ($q) {
$q->where('ends_at', '<=', now());
});
});
}
/**
* Scope: Users without any active subscriptions
*/
public function scopeWithoutActiveSubscription($query)
{
return $query->whereDoesntHave('subscriptions', function ($subscriptionQuery) {
$subscriptionQuery->where(function ($q) {
$q->where('status', 'active')
->orWhere('status', 'trialing');
})->where(function ($q) {
$q->whereNull('ends_at')
->orWhere('ends_at', '>', now());
});
});
}
/**
* Scope: Users by subscription provider
*/
public function scopeBySubscriptionProvider($query, string $provider)
{
return $query->whereHas('subscriptions', function ($subscriptionQuery) use ($provider) {
$subscriptionQuery->where('provider', $provider)
->where(function ($q) {
$q->whereNull('ends_at')
->orWhere('ends_at', '>', now());
});
});
}
/**
* Scope: Users with subscriptions expiring soon (within given days)
*/
public function scopeWithSubscriptionExpiringSoon($query, int $days = 7)
{
return $query->whereHas('subscriptions', function ($subscriptionQuery) use ($days) {
$subscriptionQuery->where('status', 'active')
->whereNotNull('ends_at')
->where('ends_at', '<=', now()->addDays($days))
->where('ends_at', '>', now());
});
}
/**
* Check if user has an active subscription
*/
public function hasActiveSubscription(): bool
{
return $this->subscriptions()
->where(function ($query) {
$query->where('status', 'active')
->orWhere('status', 'trialing');
})
->where(function ($query) {
$query->whereNull('ends_at')
->orWhere('ends_at', '>', now());
})
->exists();
}
/**
* Check if user is currently on trial
*/
public function isOnTrial(): bool
{
return $this->subscriptions()
->where('status', 'trialing')
->where('trial_ends_at', '>', now())
->exists();
}
/**
* Check if user has cancelled subscription
*/
public function hasCancelledSubscription(): bool
{
return $this->subscriptions()
->where(function ($query) {
$query->where('status', 'cancelled')
->orWhere(function ($q) {
$q->whereNotNull('ends_at')
->where('ends_at', '<=', now());
});
})
->exists();
}
/**
* Check if user has ever had a subscription
*/
public function hasHadSubscription(): bool
{
return $this->subscriptions()->exists();
}
/**
* Get user's subscription status as string
*/
public function getSubscriptionStatus(): string
{
if ($this->isOnTrial()) {
return 'trialing';
}
if ($this->hasActiveSubscription()) {
return 'active';
}
if ($this->hasCancelledSubscription()) {
return 'cancelled';
}
return 'none';
}
/**
* Get user's current subscription plan
*/
public function getCurrentPlan(): ?Plan
{
return $this->currentSubscription?->plan;
}
/**
* Get user's subscription expiry date
*/
public function getSubscriptionExpiryDate(): ?\Carbon\Carbon
{
return $this->currentSubscription?->ends_at;
}
/**
* Get user's trial end date
*/
public function getTrialEndDate(): ?\Carbon\Carbon
{
$trialSubscription = $this->subscriptions()
->where('status', 'trialing')
->where('trial_ends_at', '>', now())
->first();
return $trialSubscription?->trial_ends_at;
}
/**
* Check if user's subscription is expiring soon (within given days)
*/
public function isSubscriptionExpiringSoon(int $days = 7): bool
{
$currentSubscription = $this->currentSubscription;
return $currentSubscription &&
$currentSubscription->ends_at &&
$currentSubscription->ends_at->lte(now()->addDays($days)) &&
$currentSubscription->ends_at->gt(now());
}
/**
* Get total amount spent by user across all subscriptions
*/
public function getTotalSpent(): float
{
return $this->subscriptions()
->with('plan')
->get()
->sum(function ($subscription) {
return $subscription->plan ? $subscription->plan->price : 0;
});
}
/**
* Get user's subscription provider
*/
public function getSubscriptionProvider(): ?string
{
return $this->currentSubscription?->provider;
}
/**
* Check if user can upgrade/downgrade their plan
*/
public function canChangePlan(): bool
{
return $this->hasActiveSubscription() &&
$this->currentSubscription?->isRecurring();
}
/**
* Get subscription metrics for analytics
*/
public function getSubscriptionMetrics(): array
{
$subscriptions = $this->subscriptions()->with('plan')->get();
return [
'total_subscriptions' => $subscriptions->count(),
'active_subscriptions' => $subscriptions->where(function ($sub) {
return in_array($sub->status, ['active', 'trialing']) &&
(!$sub->ends_at || $sub->ends_at->isFuture());
})->count(),
'total_spent' => $this->getTotalSpent(),
'current_plan' => $this->getCurrentPlan()?->name,
'provider' => $this->getSubscriptionProvider(),
'status' => $this->getSubscriptionStatus(),
'trial_ends_at' => $this->getTrialEndDate(),
'subscription_ends_at' => $this->getSubscriptionExpiryDate(),
'is_expiring_soon' => $this->isSubscriptionExpiringSoon(),
];
}
}

View File

@@ -0,0 +1,430 @@
<?php
namespace App\Services\Payments;
use App\Services\Payments\Providers\ActivationKeyProvider;
use App\Services\Payments\Providers\CryptoProvider;
use App\Services\Payments\Providers\LemonSqueezyProvider;
use App\Services\Payments\Providers\OxapayProvider;
use App\Services\Payments\Providers\PolarProvider;
use App\Services\Payments\Providers\StripeProvider;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class PaymentConfigurationManager
{
protected ProviderRegistry $registry;
protected array $providerConfigs;
public function __construct(ProviderRegistry $registry)
{
$this->registry = $registry;
$this->providerConfigs = $this->loadProviderConfigurations();
}
/**
* Initialize all configured payment providers
*/
public function initializeProviders(): void
{
try {
$this->registerStripeProvider();
$this->registerLemonSqueezyProvider();
$this->registerPolarProvider();
$this->registerOxapayProvider();
$this->registerCryptoProvider();
$this->registerActivationKeyProvider();
Log::info('Payment providers initialized', [
'providers' => array_keys($this->registry->getAllProviders()->toArray()),
'active_providers' => array_keys($this->registry->getActiveProviders()->toArray()),
]);
} catch (\Exception $e) {
Log::error('Failed to initialize payment providers', [
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Register Stripe provider if configured
*/
protected function registerStripeProvider(): void
{
$config = $this->providerConfigs['stripe'] ?? [];
if (! empty($config['secret_key'])) {
$provider = new StripeProvider($config);
$this->registry->register('stripe', $provider);
}
}
/**
* Register Lemon Squeezy provider if configured
*/
protected function registerLemonSqueezyProvider(): void
{
$config = $this->providerConfigs['lemon_squeezy'] ?? [];
if (! empty($config['api_key']) && ! empty($config['store_id'])) {
$provider = new LemonSqueezyProvider($config);
$this->registry->register('lemon_squeezy', $provider);
}
}
/**
* Register Polar provider if configured
*/
protected function registerPolarProvider(): void
{
$config = $this->providerConfigs['polar'] ?? [];
if (! empty($config['api_key'])) {
$provider = new PolarProvider($config);
$this->registry->register('polar', $provider);
}
}
/**
* Register OxaPay provider if configured
*/
protected function registerOxapayProvider(): void
{
$config = $this->providerConfigs['oxapay'] ?? [];
if (! empty($config['merchant_api_key'])) {
$provider = new OxapayProvider($config);
$this->registry->register('oxapay', $provider);
}
}
/**
* Register Crypto provider if enabled
*/
protected function registerCryptoProvider(): void
{
$config = $this->providerConfigs['crypto'] ?? [];
if ($config['enabled'] ?? false) {
$provider = new CryptoProvider($config);
$this->registry->register('crypto', $provider);
}
}
/**
* Register Activation Key provider (always available)
*/
protected function registerActivationKeyProvider(): void
{
$config = $this->providerConfigs['activation_key'] ?? [];
$provider = new ActivationKeyProvider($config);
$this->registry->register('activation_key', $provider);
}
/**
* Load provider configurations from config and cache
*/
protected function loadProviderConfigurations(): array
{
return Cache::remember('payment_provider_configs', now()->addHour(), function () {
return [
'stripe' => [
'secret_key' => config('services.stripe.secret_key'),
'publishable_key' => config('services.stripe.publishable_key'),
'webhook_secret' => config('services.stripe.webhook_secret'),
],
'lemon_squeezy' => [
'api_key' => config('services.lemon_squeezy.api_key'),
'store_id' => config('services.lemon_squeezy.store_id'),
'webhook_secret' => config('services.lemon_squeezy.webhook_secret'),
],
'polar' => [
'api_key' => config('services.polar.api_key'),
'webhook_secret' => config('services.polar.webhook_secret'),
],
'oxapay' => [
'merchant_api_key' => config('services.oxapay.merchant_api_key'),
'webhook_url' => config('services.oxapay.webhook_url'),
'success_url' => config('services.oxapay.success_url'),
'cancel_url' => config('services.oxapay.cancel_url'),
'sandbox' => config('services.oxapay.sandbox', false),
],
'crypto' => [
'enabled' => config('payments.crypto.enabled', false),
'webhook_secret' => config('payments.crypto.webhook_secret'),
'confirmation_timeout_minutes' => config('payments.crypto.confirmation_timeout_minutes', 30),
'exchange_rate_provider' => config('payments.crypto.exchange_rate_provider', 'coingecko'),
],
'activation_key' => [
'key_prefix' => config('payments.activation_key.prefix', 'AK-'),
'key_length' => config('payments.activation_key.length', 32),
'expiration_days' => config('payments.activation_key.expiration_days'),
],
];
});
}
/**
* Get provider configuration
*/
public function getProviderConfig(string $provider): array
{
return $this->providerConfigs[$provider] ?? [];
}
/**
* Update provider configuration
*/
public function updateProviderConfig(string $provider, array $config): void
{
$this->providerConfigs[$provider] = array_merge(
$this->providerConfigs[$provider] ?? [],
$config
);
// Clear cache to force reload
Cache::forget('payment_provider_configs');
Log::info('Payment provider configuration updated', [
'provider' => $provider,
]);
}
/**
* Validate provider configuration
*/
public function validateProviderConfig(string $provider, array $config): array
{
$errors = [];
switch ($provider) {
case 'stripe':
if (empty($config['secret_key'])) {
$errors[] = 'Stripe secret key is required';
}
if (empty($config['publishable_key'])) {
$errors[] = 'Stripe publishable key is required';
}
break;
case 'lemon_squeezy':
if (empty($config['api_key'])) {
$errors[] = 'Lemon Squeezy API key is required';
}
if (empty($config['store_id'])) {
$errors[] = 'Lemon Squeezy store ID is required';
}
break;
case 'polar':
if (empty($config['api_key'])) {
$errors[] = 'Polar API key is required';
}
break;
case 'oxapay':
if (empty($config['merchant_api_key'])) {
$errors[] = 'OxaPay merchant API key is required';
}
break;
case 'crypto':
if (empty($config['webhook_secret'])) {
$errors[] = 'Crypto webhook secret is required';
}
break;
case 'activation_key':
// Activation keys don't require specific configuration
break;
default:
$errors[] = "Unknown provider: {$provider}";
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
/**
* Get provider status and health information
*/
public function getProviderStatus(): array
{
$status = [];
$providers = $this->registry->getAllProviders();
foreach ($providers as $name => $provider) {
$status[$name] = [
'name' => $provider->getName(),
'active' => $provider->isActive(),
'supports_recurring' => $provider->supportsRecurring(),
'supports_one_time' => $provider->supportsOneTime(),
'supported_currencies' => $provider->getSupportedCurrencies(),
'configured' => ! empty($this->providerConfigs[$name]),
'configuration' => $this->sanitizeConfig($this->providerConfigs[$name] ?? []),
];
}
return $status;
}
/**
* Sanitize configuration for display (remove sensitive data)
*/
protected function sanitizeConfig(array $config): array
{
$sensitiveKeys = ['secret_key', 'api_key', 'webhook_secret'];
$sanitized = $config;
foreach ($sensitiveKeys as $key) {
if (isset($sanitized[$key]) && ! empty($sanitized[$key])) {
$sanitized[$key] = '***'.substr($sanitized[$key], -4);
}
}
return $sanitized;
}
/**
* Enable/disable a provider
*/
public function toggleProvider(string $provider, bool $enabled): bool
{
try {
if ($enabled && ! $this->registry->has($provider)) {
// Register the provider if it doesn't exist
$this->registerProviderByName($provider);
}
if (! $enabled && $this->registry->has($provider)) {
// Unregister the provider
$this->registry->unregister($provider);
}
$this->updateProviderConfig($provider, ['enabled' => $enabled]);
Log::info('Payment provider toggled', [
'provider' => $provider,
'enabled' => $enabled,
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to toggle payment provider', [
'provider' => $provider,
'enabled' => $enabled,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Register a provider by name
*/
protected function registerProviderByName(string $provider): void
{
switch ($provider) {
case 'stripe':
$this->registerStripeProvider();
break;
case 'lemon_squeezy':
$this->registerLemonSqueezyProvider();
break;
case 'polar':
$this->registerPolarProvider();
break;
case 'oxapay':
$this->registerOxapayProvider();
break;
case 'crypto':
$this->registerCryptoProvider();
break;
case 'activation_key':
$this->registerActivationKeyProvider();
break;
default:
throw new \InvalidArgumentException("Unknown provider: {$provider}");
}
}
/**
* Get default provider for a given plan type
*/
public function getDefaultProvider(?string $planType = null): string
{
// Priority order for providers
$priority = [
'stripe', // Most reliable
'lemon_squeezy', // Good for international
'polar', // Developer-focused MoR
'oxapay', // Crypto payment gateway
'crypto', // For crypto payments
'activation_key', // For manual activation
];
foreach ($priority as $provider) {
if ($this->registry->has($provider) && $this->registry->get($provider)->isActive()) {
return $provider;
}
}
// Fallback to activation key (always available)
return 'activation_key';
}
/**
* Test provider connectivity
*/
public function testProviderConnectivity(string $provider): array
{
try {
if (! $this->registry->has($provider)) {
return [
'success' => false,
'error' => 'Provider not registered',
];
}
$providerInstance = $this->registry->get($provider);
// Basic connectivity test - check if provider is active
$isActive = $providerInstance->isActive();
return [
'success' => $isActive,
'message' => $isActive ? 'Provider is active and ready' : 'Provider is not active',
'details' => [
'name' => $providerInstance->getName(),
'supports_recurring' => $providerInstance->supportsRecurring(),
'supports_one_time' => $providerInstance->supportsOneTime(),
],
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Refresh provider configurations
*/
public function refreshConfigurations(): void
{
Cache::forget('payment_provider_configs');
$this->providerConfigs = $this->loadProviderConfigurations();
Log::info('Payment provider configurations refreshed');
}
}

View File

@@ -0,0 +1,431 @@
<?php
namespace App\Services\Payments;
use App\Models\PaymentEvent;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class PaymentLogger
{
protected array $context = [];
public function __construct()
{
$this->context = [
'request_id' => uniqid('pay_', true),
'timestamp' => now()->toISOString(),
'user_agent' => $this->getUserAgent(),
'ip_address' => $this->getIpAddress(),
];
}
/**
* Log a payment event
*/
public function logEvent(string $eventType, array $data = [], ?string $level = 'info'): void
{
$eventData = array_merge($this->context, [
'event_type' => $eventType,
'user_id' => $this->getUserId(),
'data' => $data,
'level' => $level,
]);
// Log to Laravel logs
$this->logToFile("Payment event: {$eventType}", $eventData, $level);
// Store in database for audit trail
$this->storeEvent($eventType, $data, $level);
}
/**
* Log an error event
*/
public function logError(string $eventType, array $data = [], ?\Exception $exception = null): void
{
$errorData = $data;
if ($exception) {
$errorData['exception'] = [
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
];
}
$this->logEvent($eventType, $errorData, 'error');
}
/**
* Log a security event
*/
public function logSecurityEvent(string $eventType, array $data = []): void
{
$securityData = array_merge($data, [
'security_level' => 'high',
'requires_review' => true,
]);
$this->logEvent("security_{$eventType}", $securityData, 'warning');
// Additional security logging
$this->logToFile("Security payment event: {$eventType}", array_merge($this->context, $securityData), 'warning');
}
/**
* Log webhook events
*/
public function logWebhook(string $provider, string $eventType, array $payload, bool $success = true): void
{
$webhookData = [
'provider' => $provider,
'webhook_event_type' => $eventType,
'payload_size' => strlen(json_encode($payload)),
'payload_hash' => hash('sha256', json_encode($payload)),
'success' => $success,
];
// Don't store full payload in logs for security/size reasons
$this->logEvent('webhook_received', $webhookData, $success ? 'info' : 'error');
// Store full payload in database for debugging (with retention policy)
$this->storeWebhookPayload($provider, $eventType, $payload, $success);
}
/**
* Log subscription lifecycle events
*/
public function logSubscriptionEvent(string $action, int $subscriptionId, array $data = []): void
{
$subscriptionData = array_merge($data, [
'subscription_id' => $subscriptionId,
'action' => $action,
]);
$this->logEvent("subscription_{$action}", $subscriptionData);
}
/**
* Log payment method events
*/
public function logPaymentMethodEvent(string $action, array $data = []): void
{
$this->logEvent("payment_method_{$action}", $data);
}
/**
* Log provider events
*/
public function logProviderEvent(string $provider, string $action, array $data = []): void
{
$providerData = array_merge($data, [
'provider' => $provider,
'provider_action' => $action,
]);
$this->logEvent("provider_{$action}", $providerData);
}
/**
* Log admin actions
*/
public function logAdminAction(string $action, array $data = []): void
{
$adminData = array_merge($data, [
'admin_user_id' => Auth::id(),
'admin_action' => $action,
'requires_review' => in_array($action, ['refund', 'subscription_override', 'provider_config_change']),
]);
$this->logEvent("admin_{$action}", $adminData);
}
/**
* Log migration events
*/
public function logMigrationEvent(string $action, array $data = []): void
{
$migrationData = array_merge($data, [
'migration_action' => $action,
'batch_id' => $data['batch_id'] ?? null,
]);
$this->logEvent("migration_{$action}", $migrationData);
}
/**
* Log compliance events
*/
public function logComplianceEvent(string $type, array $data = []): void
{
$complianceData = array_merge($data, [
'compliance_type' => $type,
'retention_required' => true,
'gdpr_relevant' => in_array($type, ['data_access', 'data_deletion', 'consent_withdrawn']),
]);
$this->logEvent("compliance_{$type}", $complianceData);
}
/**
* Get audit trail for a specific user
*/
public function getUserAuditTrail(int $userId, array $filters = []): array
{
$query = PaymentEvent::where('user_id', $userId);
if (! empty($filters['event_type'])) {
$query->where('event_type', $filters['event_type']);
}
if (! empty($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->where('created_at', '<=', $filters['date_to']);
}
if (! empty($filters['level'])) {
$query->where('level', $filters['level']);
}
return $query->orderBy('created_at', 'desc')
->limit($filters['limit'] ?? 1000)
->get()
->toArray();
}
/**
* Get audit trail for a subscription
*/
public function getSubscriptionAuditTrail(int $subscriptionId): array
{
return PaymentEvent::whereJsonContains('data->subscription_id', $subscriptionId)
->orderBy('created_at', 'desc')
->get()
->toArray();
}
/**
* Get provider audit trail
*/
public function getProviderAuditTrail(string $provider, array $filters = []): array
{
$query = PaymentEvent::whereJsonContains('data->provider', $provider);
if (! empty($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->where('created_at', '<=', $filters['date_to']);
}
return $query->orderBy('created_at', 'desc')
->limit($filters['limit'] ?? 1000)
->get()
->toArray();
}
/**
* Generate compliance report
*/
public function generateComplianceReport(array $criteria = []): array
{
$query = PaymentEvent::query();
if (! empty($criteria['date_from'])) {
$query->where('created_at', '>=', $criteria['date_from']);
}
if (! empty($criteria['date_to'])) {
$query->where('created_at', '<=', $criteria['date_to']);
}
if (! empty($criteria['event_types'])) {
$query->whereIn('event_type', $criteria['event_types']);
}
$events = $query->get();
return [
'report_generated_at' => now()->toISOString(),
'criteria' => $criteria,
'total_events' => $events->count(),
'events_by_type' => $events->groupBy('event_type')->map->count(),
'events_by_level' => $events->groupBy('level')->map->count(),
'security_events' => $events->filter(fn ($e) => str_contains($e->event_type, 'security'))->count(),
'compliance_events' => $events->filter(fn ($e) => str_contains($e->event_type, 'compliance'))->count(),
'retention_summary' => $this->getRetentionSummary($events),
];
}
/**
* Store event in database
*/
protected function storeEvent(string $eventType, array $data, string $level): void
{
try {
PaymentEvent::create([
'event_type' => $eventType,
'user_id' => Auth::id(),
'level' => $level,
'data' => array_merge($this->context, $data),
'ip_address' => $this->context['ip_address'],
'user_agent' => $this->context['user_agent'],
'request_id' => $this->context['request_id'],
]);
} catch (\Exception $e) {
// Fallback to file logging if database fails
$this->logToFile('Failed to store payment event in database', [
'event_type' => $eventType,
'error' => $e->getMessage(),
'data' => $data,
], 'error');
}
}
/**
* Store webhook payload
*/
protected function storeWebhookPayload(string $provider, string $eventType, array $payload, bool $success): void
{
try {
PaymentEvent::create([
'event_type' => 'webhook_payload',
'level' => $success ? 'info' : 'error',
'data' => [
'provider' => $provider,
'webhook_event_type' => $eventType,
'payload' => $payload,
'success' => $success,
'stored_at' => now()->toISOString(),
] + $this->context,
'ip_address' => $this->context['ip_address'],
'user_agent' => $this->context['user_agent'],
'request_id' => $this->context['request_id'],
'expires_at' => now()->addDays(30), // Webhook payloads expire after 30 days
]);
} catch (\Exception $e) {
$this->logToFile('Failed to store webhook payload', [
'provider' => $provider,
'event_type' => $eventType,
'error' => $e->getMessage(),
], 'error');
}
}
/**
* Get retention summary for compliance
*/
protected function getRetentionSummary($events): array
{
$now = now();
$retentionPeriods = [
'30_days' => $now->copy()->subDays(30),
'90_days' => $now->copy()->subDays(90),
'1_year' => $now->copy()->subYear(),
'7_years' => $now->copy()->subYears(7),
];
return array_map(static function ($date) use ($events) {
return $events->where('created_at', '>=', $date)->count();
}, $retentionPeriods);
}
/**
* Clean up old events based on retention policy
*/
public function cleanupOldEvents(): array
{
$cleanupResults = [];
// Clean up webhook payloads after 30 days
$webhookCleanup = PaymentEvent::where('event_type', 'webhook_payload')
->where('expires_at', '<', now())
->delete();
$cleanupResults['webhook_payloads'] = $webhookCleanup;
// Clean up debug events after 90 days
$debugCleanup = PaymentEvent::where('level', 'debug')
->where('created_at', '<', now()->subDays(90))
->delete();
$cleanupResults['debug_events'] = $debugCleanup;
// Keep compliance and security events for 7 years
// This is handled by database retention policies
$this->logToFile('Payment event cleanup completed', $cleanupResults, 'info');
return $cleanupResults;
}
/**
* Set additional context for logging
*/
public function setContext(array $context): void
{
$this->context = array_merge($this->context, $context);
}
/**
* Get current context
*/
public function getContext(): array
{
return $this->context;
}
/**
* Get user agent safely
*/
protected function getUserAgent(): ?string
{
try {
return request()->userAgent();
} catch (\Exception $e) {
return null;
}
}
/**
* Get IP address safely
*/
protected function getIpAddress(): ?string
{
try {
return request()->ip();
} catch (\Exception $e) {
return null;
}
}
/**
* Get user ID safely
*/
protected function getUserId(): ?int
{
try {
return Auth::id();
} catch (\Exception $e) {
return null;
}
}
/**
* Log to file safely
*/
protected function logToFile(string $message, array $context, string $level): void
{
try {
Log::{$level}($message, $context);
} catch (\Exception $e) {
// Silently fail if logging isn't available
}
}
}

View File

@@ -0,0 +1,910 @@
<?php
namespace App\Services\Payments;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Coupon;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\SubscriptionChange;
use App\Models\User;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class PaymentOrchestrator
{
protected ProviderRegistry $providerRegistry;
protected PaymentLogger $logger;
public function __construct(ProviderRegistry $providerRegistry, PaymentLogger $logger)
{
$this->providerRegistry = $providerRegistry;
$this->logger = $logger;
}
/**
* Create a new subscription using the preferred provider
*/
public function createSubscription(User $user, Plan $plan, ?string $providerName = null, array $options = []): array
{
$provider = $this->getProviderForPlan($plan, $providerName);
if (! $provider->isActive()) {
throw new Exception("Payment provider {$provider->getName()} is not active");
}
try {
$this->logger->logEvent('subscription_creation_started', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => $provider->getName(),
'options' => $options,
]);
$result = $provider->createSubscription($user, $plan, $options);
// Create local subscription record
$subscription = $this->createLocalSubscription($user, $plan, $provider, $result);
$this->logger->logEvent('subscription_created', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'provider_subscription_id' => $result['provider_subscription_id'] ?? null,
]);
return [
'success' => true,
'subscription' => $subscription,
'provider_data' => $result,
];
} catch (Exception $e) {
$this->logger->logError('subscription_creation_failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Cancel a subscription
*/
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
$provider = $this->getProviderForSubscription($subscription);
try {
$this->logger->logEvent('subscription_cancellation_started', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'reason' => $reason,
]);
$result = $provider->cancelSubscription($subscription, $reason);
if ($result) {
$subscription->update([
'ends_at' => now(),
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
$this->logger->logEvent('subscription_cancelled', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
]);
}
return $result;
} catch (Exception $e) {
$this->logger->logError('subscription_cancellation_failed', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Update subscription plan
*/
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
$provider = $this->getProviderForSubscription($subscription);
try {
$this->logger->logEvent('subscription_update_started', [
'subscription_id' => $subscription->id,
'old_plan_id' => $subscription->plan_id,
'new_plan_id' => $newPlan->id,
'provider' => $provider->getName(),
]);
$result = $provider->updateSubscription($subscription, $newPlan);
$subscription->update([
'plan_id' => $newPlan->id,
'updated_at' => now(),
]);
$this->logger->logEvent('subscription_updated', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'provider' => $provider->getName(),
]);
return [
'success' => true,
'subscription' => $subscription->fresh(),
'provider_data' => $result,
];
} catch (Exception $e) {
$this->logger->logError('subscription_update_failed', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Create checkout session
*/
public function createCheckoutSession(User $user, Plan $plan, ?string $providerName = null, array $options = []): array
{
$provider = $this->getProviderForPlan($plan, $providerName);
try {
$this->logger->logEvent('checkout_session_created', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => $provider->getName(),
]);
return $provider->createCheckoutSession($user, $plan, $options);
} catch (Exception $e) {
$this->logger->logError('checkout_session_failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Create subscription with coupon
*/
public function createSubscriptionWithCoupon(User $user, Plan $plan, Coupon $coupon, ?string $providerName = null, array $options = []): array
{
$provider = $this->getProviderForPlan($plan, $providerName);
if (! $coupon->isValid($user)) {
throw new Exception("Coupon {$coupon->code} is not valid for this user");
}
try {
$this->logger->logEvent('coupon_subscription_creation_started', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'coupon_id' => $coupon->id,
'provider' => $provider->getName(),
]);
// Add coupon to options
$options['coupon'] = $coupon->code;
$options['discount_amount'] = $this->calculateDiscountAmount($plan, $coupon);
$result = $provider->createSubscription($user, $plan, $options);
$subscription = $this->createLocalSubscription($user, $plan, $provider, $result);
// Apply coupon to subscription
$couponUsage = $subscription->applyCoupon($coupon, $options['discount_amount']);
$this->logger->logEvent('coupon_subscription_created', [
'subscription_id' => $subscription->id,
'coupon_id' => $coupon->id,
'coupon_usage_id' => $couponUsage->id,
'provider' => $provider->getName(),
]);
return [
'success' => true,
'subscription' => $subscription,
'coupon_usage' => $couponUsage,
'provider_data' => $result,
];
} catch (Exception $e) {
$this->logger->logError('coupon_subscription_creation_failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'coupon_id' => $coupon->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Extend subscription trial
*/
public function extendTrial(Subscription $subscription, int $days, string $reason = '', string $extensionType = 'manual', ?User $grantedBy = null): array
{
$provider = $this->getProviderForSubscription($subscription);
if (! $subscription->isOnTrial()) {
throw new Exception("Subscription {$subscription->id} is not on trial");
}
try {
$this->logger->logEvent('trial_extension_started', [
'subscription_id' => $subscription->id,
'days' => $days,
'reason' => $reason,
'extension_type' => $extensionType,
'granted_by' => $grantedBy?->id,
]);
// Create trial extension record
$trialExtension = $subscription->extendTrial($days, $reason, $extensionType, $grantedBy);
// Update provider if supported
if (method_exists($provider, 'extendTrial')) {
$provider->extendTrial($subscription, $days, $reason);
}
$this->logger->logEvent('trial_extended', [
'subscription_id' => $subscription->id,
'trial_extension_id' => $trialExtension->id,
'new_trial_ends_at' => $trialExtension->new_trial_ends_at,
]);
return [
'success' => true,
'trial_extension' => $trialExtension,
'subscription' => $subscription->fresh(),
];
} catch (Exception $e) {
$this->logger->logError('trial_extension_failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Record subscription change
*/
public function recordSubscriptionChange(Subscription $subscription, string $changeType, string $description, ?array $oldValues = null, ?array $newValues = null, ?string $reason = null): SubscriptionChange
{
try {
$this->logger->logEvent('subscription_change_recorded', [
'subscription_id' => $subscription->id,
'change_type' => $changeType,
'description' => $description,
]);
return $subscription->recordChange($changeType, $description, $oldValues, $newValues, $reason);
} catch (Exception $e) {
$this->logger->logError('subscription_change_recording_failed', [
'subscription_id' => $subscription->id,
'change_type' => $changeType,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Process pending subscription changes
*/
public function processPendingChanges(Subscription $subscription): array
{
try {
$pendingChanges = $subscription->getPendingChanges();
$processedCount = 0;
$errors = [];
foreach ($pendingChanges as $change) {
try {
$this->processSubscriptionChange($subscription, $change);
$change->markAsProcessed();
$processedCount++;
} catch (Exception $e) {
$errors[] = [
'change_id' => $change->id,
'error' => $e->getMessage(),
];
$this->logger->logError('subscription_change_processing_failed', [
'change_id' => $change->id,
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
$this->logger->logEvent('pending_changes_processed', [
'subscription_id' => $subscription->id,
'processed_count' => $processedCount,
'error_count' => count($errors),
]);
return [
'success' => true,
'processed_count' => $processedCount,
'errors' => $errors,
'subscription' => $subscription->fresh(),
];
} catch (Exception $e) {
$this->logger->logError('pending_changes_processing_failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Migrate subscription between providers
*/
public function migrateSubscription(Subscription $subscription, string $targetProvider): array
{
$sourceProvider = $this->getProviderForSubscription($subscription);
$targetProviderInstance = $this->providerRegistry->get($targetProvider);
if (! $targetProviderInstance) {
throw new Exception("Target provider {$targetProvider} not found");
}
if (! $targetProviderInstance->isActive()) {
throw new Exception("Target provider {$targetProvider} is not active");
}
try {
$this->logger->logEvent('subscription_migration_started', [
'subscription_id' => $subscription->id,
'source_provider' => $sourceProvider->getName(),
'target_provider' => $targetProvider,
]);
// Record the change
$this->recordSubscriptionChange(
$subscription,
'migration',
"Migrated from {$sourceProvider->getName()} to {$targetProvider}",
['provider' => $sourceProvider->getName()],
['provider' => $targetProvider],
'Provider migration for better service'
);
// Cancel with source provider if needed
if (method_exists($sourceProvider, 'cancelSubscription')) {
$sourceProvider->cancelSubscription($subscription, 'Migration to new provider');
}
// Create with target provider
$newSubscriptionData = $targetProviderInstance->createSubscription(
$subscription->user,
$subscription->plan,
['migration' => true]
);
// Update local subscription
$subscription->update([
'provider' => $targetProvider,
'provider_subscription_id' => $newSubscriptionData['provider_subscription_id'] ?? null,
'provider_data' => $newSubscriptionData,
'migration_batch_id' => uniqid('migration_', true),
'is_migrated' => true,
'last_provider_sync' => now(),
]);
$this->logger->logEvent('subscription_migration_completed', [
'subscription_id' => $subscription->id,
'migration_batch_id' => $subscription->migration_batch_id,
'target_provider' => $targetProvider,
]);
return [
'success' => true,
'subscription' => $subscription->fresh(),
'provider_data' => $newSubscriptionData,
];
} catch (Exception $e) {
$this->logger->logError('subscription_migration_failed', [
'subscription_id' => $subscription->id,
'source_provider' => $sourceProvider->getName(),
'target_provider' => $targetProvider,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Process webhook from any provider
*/
public function processWebhook(string $providerName, Request $request): array
{
$provider = $this->providerRegistry->get($providerName);
if (! $provider) {
$this->logger->logError('webhook_provider_not_found', [
'provider' => $providerName,
]);
throw new Exception("Payment provider {$providerName} not found");
}
if (! $provider->validateWebhook($request)) {
$this->logger->logError('webhook_validation_failed', [
'provider' => $providerName,
]);
throw new Exception("Webhook validation failed for {$providerName}");
}
try {
$result = $provider->processWebhook($request);
$this->logger->logEvent('webhook_processed', [
'provider' => $providerName,
'event_type' => $result['event_type'] ?? 'unknown',
'subscription_id' => $result['subscription_id'] ?? null,
]);
return $result;
} catch (Exception $e) {
$this->logger->logError('webhook_processing_failed', [
'provider' => $providerName,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Sync subscription status from provider
*/
public function syncSubscriptionStatus(Subscription $subscription): array
{
$provider = $this->getProviderForSubscription($subscription);
try {
$providerData = $provider->syncSubscriptionStatus($subscription);
// Update local subscription based on provider data
$this->updateLocalSubscriptionFromProvider($subscription, $providerData);
$this->logger->logEvent('subscription_synced', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'status' => $providerData['status'] ?? 'unknown',
]);
return $providerData;
} catch (Exception $e) {
$this->logger->logError('subscription_sync_failed', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Get active providers for a plan
*/
public function getActiveProvidersForPlan(Plan $plan): Collection
{
return $this->providerRegistry->getActiveProviders()
->filter(function ($provider) use ($plan) {
return $this->isProviderSupportedForPlan($provider, $plan);
});
}
/**
* Get subscription transaction history
*/
public function getTransactionHistoryOld(User $user, array $filters = []): array
{
$subscriptions = $user->subscriptions;
$history = [];
foreach ($subscriptions as $subscription) {
$provider = $this->getProviderForSubscription($subscription);
$providerHistory = $provider->getTransactionHistory($user, $filters);
$history = array_merge($history, $providerHistory);
}
// Sort by date descending
usort($history, function ($a, $b) {
return strtotime($b['date']) - strtotime($a['date']);
});
return $history;
}
public function getTransactionHistory(User $user, array $filters = []): array
{
$subscriptions = $user->subscriptions;
$history = [];
foreach ($subscriptions as $subscription) {
$provider = $this->getProviderForSubscription($subscription);
$providerHistory = $provider->getTransactionHistory($user, $filters);
// Use array_push with spread operator (PHP 7.4+) or array unpacking
array_push($history, ...$providerHistory);
// Alternative: Direct array concatenation
// foreach ($providerHistory as $transaction) {
// $history[] = $transaction;
// }
}
// Sort by date descending
usort($history, function ($a, $b) {
return strtotime($b['date']) - strtotime($a['date']);
});
return $history;
}
/**
* Get provider for a specific plan
*/
protected function getProviderForPlan(Plan $plan, ?string $providerName = null): PaymentProviderContract
{
if ($providerName) {
$provider = $this->providerRegistry->get($providerName);
if ($provider && $provider->isActive() && $this->isProviderSupportedForPlan($provider, $plan)) {
return $provider;
}
}
// Find the first active provider that supports this plan
foreach ($this->providerRegistry->getActiveProviders() as $provider) {
if ($this->isProviderSupportedForPlan($provider, $plan)) {
return $provider;
}
}
throw new Exception("No active payment provider available for plan: {$plan->name}");
}
/**
* Get provider for existing subscription
*/
protected function getProviderForSubscription(Subscription $subscription): PaymentProviderContract
{
$providerName = $subscription->provider ?? 'stripe'; // Default to stripe for existing subscriptions
$provider = $this->providerRegistry->get($providerName);
if (! $provider) {
throw new Exception("Payment provider {$providerName} not found for subscription {$subscription->id}");
}
return $provider;
}
/**
* Check if provider supports a specific plan
*/
protected function isProviderSupportedForPlan(PaymentProviderContract $provider, Plan $plan): bool
{
// Check if plan has provider-specific configuration
$providerConfig = $plan->details['providers'][$provider->getName()] ?? null;
if (! $providerConfig || ! ($providerConfig['enabled'] ?? false)) {
return false;
}
// Check if provider supports the plan type
if ($plan->monthly_billing && ! $provider->supportsRecurring()) {
return false;
}
if (! $plan->monthly_billing && ! $provider->supportsOneTime()) {
return false;
}
return true;
}
/**
* Create local subscription record
*/
protected function createLocalSubscription(User $user, Plan $plan, PaymentProviderContract $provider, array $providerData): Subscription
{
return Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => $provider->getName(),
'provider_subscription_id' => $providerData['provider_subscription_id'] ?? null,
'status' => $providerData['status'] ?? 'active',
'starts_at' => $providerData['starts_at'] ?? now(),
'ends_at' => $providerData['ends_at'] ?? null,
'trial_ends_at' => $providerData['trial_ends_at'] ?? null,
'provider_data' => $providerData,
]);
}
/**
* Update local subscription from provider data
*/
protected function updateLocalSubscriptionFromProvider(Subscription $subscription, array $providerData): void
{
$subscription->update([
'status' => $providerData['status'] ?? $subscription->status,
'ends_at' => $providerData['ends_at'] ?? $subscription->ends_at,
'trial_ends_at' => $providerData['trial_ends_at'] ?? $subscription->trial_ends_at,
'provider_data' => array_merge($subscription->provider_data ?? [], $providerData),
'synced_at' => now(),
]);
}
/**
* Get all available providers
*/
public function getAvailableProviders(): Collection
{
return $this->providerRegistry->getAllProviders();
}
/**
* Get active providers only
*/
public function getActiveProviders(): Collection
{
return $this->providerRegistry->getActiveProviders();
}
/**
* Calculate discount amount for a coupon
*/
protected function calculateDiscountAmount(Plan $plan, Coupon $coupon): float
{
$planPrice = $plan->price ?? 0;
return match ($coupon->discount_type) {
'percentage' => ($planPrice * $coupon->discount_value) / 100,
'fixed' => $coupon->discount_value,
default => 0,
};
}
/**
* Process individual subscription change
*/
protected function processSubscriptionChange(Subscription $subscription, SubscriptionChange $change): void
{
match ($change->change_type) {
'plan_upgrade', 'plan_downgrade' => $this->processPlanChange($subscription, $change),
'pause' => $this->processPauseChange($subscription, $change),
'resume' => $this->processResumeChange($subscription, $change),
'cancel' => $this->processCancelChange($subscription, $change),
default => throw new Exception("Unknown change type: {$change->change_type}"),
};
}
/**
* Process plan change
*/
protected function processPlanChange(Subscription $subscription, SubscriptionChange $change): void
{
$newPlanId = $change->new_values['plan_id'] ?? null;
if (! $newPlanId) {
throw new Exception('Plan ID not found in change values');
}
$newPlan = Plan::findOrFail($newPlanId);
$result = $this->updateSubscription($subscription, $newPlan);
if (! $result['success']) {
throw new Exception('Failed to update subscription plan');
}
}
/**
* Process pause change
*/
protected function processPauseChange(Subscription $subscription, SubscriptionChange $change): void
{
$provider = $this->getProviderForSubscription($subscription);
if (method_exists($provider, 'pauseSubscription')) {
$provider->pauseSubscription($subscription);
}
$subscription->update([
'status' => 'paused',
'paused_at' => now(),
]);
}
/**
* Process resume change
*/
protected function processResumeChange(Subscription $subscription, SubscriptionChange $change): void
{
$provider = $this->getProviderForSubscription($subscription);
if (method_exists($provider, 'resumeSubscription')) {
$provider->resumeSubscription($subscription);
}
$subscription->update([
'status' => 'active',
'resumed_at' => now(),
]);
}
/**
* Process cancel change
*/
protected function processCancelChange(Subscription $subscription, SubscriptionChange $change): void
{
$reason = $change->new_values['reason'] ?? 'Scheduled cancellation';
$this->cancelSubscription($subscription, $reason);
}
/**
* Get subscription analytics
*/
public function getSubscriptionAnalytics(array $filters = []): array
{
$query = Subscription::query();
if (isset($filters['provider'])) {
$query->where('provider', $filters['provider']);
}
if (isset($filters['status'])) {
$query->where('status', $filters['status']);
}
if (isset($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
$totalSubscriptions = $query->count();
$activeSubscriptions = $query->where('status', 'active')->count();
$trialSubscriptions = $query->where('status', 'trialing')->count();
$cancelledSubscriptions = $query->where('status', 'cancelled')->count();
$mrr = $query->where('status', 'active')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->sum('plans.price');
$totalRevenue = $query->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->sum('plans.price');
return [
'total_subscriptions' => $totalSubscriptions,
'active_subscriptions' => $activeSubscriptions,
'trial_subscriptions' => $trialSubscriptions,
'cancelled_subscriptions' => $cancelledSubscriptions,
'monthly_recurring_revenue' => $mrr,
'total_revenue' => $totalRevenue,
'churn_rate' => $totalSubscriptions > 0 ? ($cancelledSubscriptions / $totalSubscriptions) * 100 : 0,
'trial_conversion_rate' => $trialSubscriptions > 0 ? (($activeSubscriptions - $trialSubscriptions) / $trialSubscriptions) * 100 : 0,
];
}
/**
* Get coupon analytics
*/
public function getCouponAnalytics(array $filters = []): array
{
$query = CouponUsage::query();
if (isset($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
$totalUsages = $query->count();
$totalDiscount = $query->sum('discount_amount');
$uniqueUsers = $query->distinct('user_id')->count('user_id');
$conversionRate = $uniqueUsers > 0 ? ($totalUsages / $uniqueUsers) * 100 : 0;
$topCoupons = $query->join('coupons', 'coupon_usages.coupon_id', '=', 'coupons.id')
->select('coupons.code', 'coupons.discount_type', 'coupons.discount_value',
DB::raw('COUNT(*) as usage_count'),
DB::raw('SUM(coupon_usages.discount_amount) as total_discount'))
->groupBy('coupons.id', 'coupons.code', 'coupons.discount_type', 'coupons.discount_value')
->orderBy('usage_count', 'desc')
->limit(10)
->get();
return [
'total_usages' => $totalUsages,
'total_discount_given' => $totalDiscount,
'unique_users' => $uniqueUsers,
'conversion_rate' => $conversionRate,
'top_performing_coupons' => $topCoupons->toArray(),
];
}
/**
* Get trial analytics
*/
public function getTrialAnalytics(array $filters = []): array
{
$query = TrialExtension::query();
if (isset($filters['date_from'])) {
$query->whereDate('granted_at', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->whereDate('granted_at', '<=', $filters['date_to']);
}
$totalExtensions = $query->count();
$totalDaysExtended = $query->sum('extension_days');
$uniqueUsers = $query->distinct('user_id')->count('user_id');
$extensionTypes = $query->select('extension_type', DB::raw('COUNT(*) as count'))
->groupBy('extension_type')
->pluck('count', 'extension_type')
->toArray();
$commonReasons = $query->select('reason', DB::raw('COUNT(*) as count'))
->whereNotNull('reason')
->groupBy('reason')
->orderBy('count', 'desc')
->limit(5)
->pluck('count', 'reason')
->toArray();
return [
'total_extensions' => $totalExtensions,
'total_days_extended' => $totalDaysExtended,
'unique_users' => $uniqueUsers,
'extension_types' => $extensionTypes,
'common_reasons' => $commonReasons,
'avg_extension_days' => $totalExtensions > 0 ? $totalDaysExtended / $totalExtensions : 0,
];
}
}

View File

@@ -0,0 +1,362 @@
<?php
namespace App\Services\Payments;
use App\Contracts\Payments\PaymentProviderContract;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ProviderRegistry
{
protected array $providers = [];
protected array $configurations = [];
public function __construct()
{
$this->loadConfigurations();
$this->registerDefaultProviders();
}
/**
* Register a payment provider
*/
public function register(string $name, PaymentProviderContract $provider): void
{
$this->providers[$name] = $provider;
Log::info('Payment provider registered', [
'provider' => $name,
'class' => get_class($provider),
]);
}
/**
* Get a specific provider
*/
public function get(string $name): ?PaymentProviderContract
{
return $this->providers[$name] ?? null;
}
/**
* Get all registered providers
*/
public function getAllProviders(): Collection
{
return collect($this->providers);
}
/**
* Get only active providers
*/
public function getActiveProviders(): Collection
{
return collect($this->providers)
->filter(fn ($provider) => $provider->isActive());
}
/**
* Check if provider exists
*/
public function has(string $name): bool
{
return isset($this->providers[$name]);
}
/**
* Unregister a provider
*/
public function unregister(string $name): bool
{
if (isset($this->providers[$name])) {
unset($this->providers[$name]);
Log::info('Payment provider unregistered', ['provider' => $name]);
return true;
}
return false;
}
/**
* Get provider configuration
*/
public function getConfiguration(string $providerName): array
{
return $this->configurations[$providerName] ?? [];
}
/**
* Update provider configuration
*/
public function updateConfiguration(string $providerName, array $config): void
{
$this->configurations[$providerName] = $config;
Cache::put("payment_config_{$providerName}", $config);
Log::info('Payment provider configuration updated', [
'provider' => $providerName,
'config_keys' => array_keys($config),
]);
}
/**
* Get providers that support recurring payments
*/
public function getRecurringProviders(): Collection
{
return $this->getActiveProviders()
->filter(fn ($provider) => $provider->supportsRecurring());
}
/**
* Get providers that support one-time payments
*/
public function getOneTimeProviders(): Collection
{
return $this->getActiveProviders()
->filter(fn ($provider) => $provider->supportsOneTime());
}
/**
* Get providers that support a specific currency
*/
public function getProvidersForCurrency(string $currency): Collection
{
return $this->getActiveProviders()
->filter(function ($provider) use ($currency) {
return in_array($currency, $provider->getSupportedCurrencies());
});
}
/**
* Get provider by webhook URL pattern
*/
public function getProviderByWebhookUrl(string $url): ?PaymentProviderContract
{
return $this->getActiveProviders()
->first(function ($provider) use ($url) {
$config = $provider->getConfiguration();
$webhookUrl = $config['webhook_url'] ?? null;
return $webhookUrl && str_contains($url, parse_url($webhookUrl, PHP_URL_PATH));
});
}
/**
* Validate provider health status
*/
public function validateProviders(): array
{
$results = [];
foreach ($this->providers as $name => $provider) {
try {
$isActive = $provider->isActive();
$config = $provider->getConfiguration();
$results[$name] = [
'active' => $isActive,
'configured' => ! empty($config),
'supports_recurring' => $provider->supportsRecurring(),
'supports_one_time' => $provider->supportsOneTime(),
'supported_currencies' => $provider->getSupportedCurrencies(),
'last_checked' => now()->toISOString(),
];
if (! $isActive) {
Log::warning('Payment provider is inactive', ['provider' => $name]);
}
} catch (\Exception $e) {
$results[$name] = [
'active' => false,
'error' => $e->getMessage(),
'last_checked' => now()->toISOString(),
];
Log::error('Payment provider health check failed', [
'provider' => $name,
'error' => $e->getMessage(),
]);
}
}
return $results;
}
/**
* Get provider statistics
*/
public function getProviderStats(): array
{
$stats = [
'total_providers' => count($this->providers),
'active_providers' => 0,
'recurring_providers' => 0,
'one_time_providers' => 0,
'supported_currencies' => [],
'providers' => [],
];
foreach ($this->providers as $name => $provider) {
$isActive = $provider->isActive();
if ($isActive) {
$stats['active_providers']++;
}
if ($provider->supportsRecurring()) {
$stats['recurring_providers']++;
}
if ($provider->supportsOneTime()) {
$stats['one_time_providers']++;
}
$stats['supported_currencies'] = array_merge(
$stats['supported_currencies'],
$provider->getSupportedCurrencies()
);
$stats['providers'][$name] = [
'active' => $isActive,
'class' => get_class($provider),
'supports_recurring' => $provider->supportsRecurring(),
'supports_one_time' => $provider->supportsOneTime(),
'currencies' => $provider->getSupportedCurrencies(),
];
}
$stats['supported_currencies'] = array_unique($stats['supported_currencies']);
return $stats;
}
/**
* Load provider configurations from cache/database
*/
protected function loadConfigurations(): void
{
// Load from cache first
$cachedConfigs = Cache::get('payment_providers_config', []);
if (empty($cachedConfigs)) {
// Load from database or config files
$this->configurations = config('payment.providers', []);
// Cache for 1 hour
Cache::put('payment_providers_config', $this->configurations, 3600);
} else {
$this->configurations = $cachedConfigs;
}
}
/**
* Register default providers
*/
protected function registerDefaultProviders(): void
{
// Auto-register providers based on configuration
$enabledProviders = config('payment.enabled_providers', []);
foreach ($enabledProviders as $providerName) {
$this->registerProviderByName($providerName);
}
}
/**
* Register provider by name using configuration
*/
protected function registerProviderByName(string $providerName): void
{
$providerClass = config("payment.providers.{$providerName}.class");
if (! $providerClass || ! class_exists($providerClass)) {
Log::error('Payment provider class not found', [
'provider' => $providerName,
'class' => $providerClass,
]);
return;
}
try {
$config = $this->getConfiguration($providerName);
$provider = new $providerClass($config);
if ($provider instanceof PaymentProviderContract) {
$this->register($providerName, $provider);
} else {
Log::error('Payment provider does not implement contract', [
'provider' => $providerName,
'class' => $providerClass,
]);
}
} catch (\Exception $e) {
Log::error('Failed to register payment provider', [
'provider' => $providerName,
'error' => $e->getMessage(),
]);
}
}
/**
* Refresh provider (useful for configuration changes)
*/
public function refreshProvider(string $name): bool
{
if (! isset($this->providers[$name])) {
return false;
}
// Unregister current instance
unset($this->providers[$name]);
// Re-register with fresh configuration
$this->registerProviderByName($name);
return isset($this->providers[$name]);
}
/**
* Enable/disable a provider
*/
public function toggleProvider(string $name, bool $enabled): bool
{
$config = $this->getConfiguration($name);
if (empty($config)) {
return false;
}
$config['enabled'] = $enabled;
$this->updateConfiguration($name, $config);
// Refresh the provider to apply changes
return $this->refreshProvider($name);
}
/**
* Get provider for fallback
*/
public function getFallbackProvider(): ?PaymentProviderContract
{
$fallbackProvider = config('payment.fallback_provider');
if ($fallbackProvider && $this->has($fallbackProvider)) {
$provider = $this->get($fallbackProvider);
if ($provider && $provider->isActive()) {
return $provider;
}
}
// Return first active provider as fallback
return $this->getActiveProviders()->first();
}
}

View File

@@ -0,0 +1,474 @@
<?php
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\ActivationKey;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ActivationKeyProvider implements PaymentProviderContract
{
protected array $config;
public function __construct(array $config = [])
{
$this->config = array_merge([
'key_prefix' => 'AK-',
'key_length' => 32,
'expiration_days' => null, // null means no expiration
'success_url' => route('payment.success'),
'cancel_url' => route('payment.cancel'),
], $config);
}
public function getName(): string
{
return 'activation_key';
}
public function isActive(): bool
{
return true; // Activation keys are always available
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
DB::beginTransaction();
// Generate a unique activation key
$activationKey = $this->generateUniqueActivationKey();
// Create activation key record
$keyRecord = ActivationKey::create([
'user_id' => $user->id,
'activation_key' => $activationKey,
'price_id' => $plan->id,
'is_activated' => false,
]);
// Create subscription record
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'type' => 'activation_key',
'stripe_id' => 'ak_'.$keyRecord->id.'_'.uniqid(more_entropy: true), // Use activation key ID + unique ID for compatibility
'stripe_status' => 'pending_activation',
'provider' => $this->getName(),
'provider_subscription_id' => $keyRecord->id,
'status' => 'pending_activation',
'starts_at' => null,
'ends_at' => null,
'provider_data' => [
'activation_key' => $activationKey,
'key_id' => $keyRecord->id,
'created_at' => now()->toISOString(),
],
]);
DB::commit();
return [
'provider_subscription_id' => $keyRecord->id,
'status' => 'pending_activation',
'activation_key' => $activationKey,
'plan_name' => $plan->name,
'plan_price' => $plan->price,
'type' => 'activation_key',
'message' => 'Activation key generated. User needs to redeem the key to activate the subscription.',
];
} catch (\Exception $e) {
DB::rollBack();
Log::error('Activation key subscription creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
// For activation keys, we don't actually cancel since it's a one-time activation
// We can deactivate the subscription if needed
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
return true;
} catch (\Exception $e) {
Log::error('Activation key subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
// Activation keys don't support plan updates
// User would need a new activation key for a different plan
throw new \Exception('Activation keys do not support plan updates');
} catch (\Exception $e) {
Log::error('Activation key subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
// Activation keys can't be paused
return false;
}
public function resumeSubscription(Subscription $subscription): bool
{
// Activation keys can't be paused, so can't be resumed
return false;
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$activationKey = ActivationKey::findOrFail($providerSubscriptionId);
return [
'id' => $activationKey->id,
'activation_key' => $activationKey->activation_key,
'user_id' => $activationKey->user_id,
'price_id' => $activationKey->price_id,
'is_activated' => $activationKey->is_activated,
'created_at' => $activationKey->created_at->toISOString(),
'updated_at' => $activationKey->updated_at->toISOString(),
];
} catch (\Exception $e) {
Log::error('Activation key subscription details retrieval failed', [
'subscription_id' => $providerSubscriptionId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
return $this->createSubscription($user, $plan, $options);
}
public function createCustomerPortalSession(User $user): array
{
return [
'portal_url' => route('dashboard'),
'message' => 'Activation keys are managed through your dashboard',
];
}
public function processWebhook(Request $request): array
{
// Activation keys don't have webhooks
return [
'event_type' => 'not_applicable',
'processed' => false,
'data' => [],
];
}
public function validateWebhook(Request $request): bool
{
// No webhooks to validate
return false;
}
public function getConfiguration(): array
{
return $this->config;
}
public function syncSubscriptionStatus(Subscription $subscription): array
{
return $this->getSubscriptionDetails($subscription->provider_subscription_id);
}
public function getPaymentMethodDetails(string $paymentMethodId): array
{
try {
$activationKey = ActivationKey::findOrFail($paymentMethodId);
return [
'id' => $activationKey->id,
'type' => 'activation_key',
'activation_key' => $activationKey->activation_key,
'is_activated' => $activationKey->is_activated,
'created_at' => $activationKey->created_at->toISOString(),
];
} catch (\Exception $e) {
Log::error('Activation key payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $paymentId, float $amount, string $reason = ''): array
{
// Activation keys are not refundable
throw new \Exception('Activation keys are not refundable');
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$query = ActivationKey::where('user_id', $user->id);
// Apply filters
if (isset($filters['status'])) {
if ($filters['status'] === 'activated') {
$query->where('is_activated', true);
} elseif ($filters['status'] === 'unactivated') {
$query->where('is_activated', false);
}
}
if (isset($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->where('created_at', '<=', $filters['date_to']);
}
$activationKeys = $query->orderBy('created_at', 'desc')->get();
$transactions = [];
foreach ($activationKeys as $key) {
$transactions[] = [
'id' => $key->id,
'activation_key' => $key->activation_key,
'plan_id' => $key->price_id,
'is_activated' => $key->is_activated,
'created_at' => $key->created_at->toISOString(),
'updated_at' => $key->updated_at->toISOString(),
];
}
return $transactions;
} catch (\Exception $e) {
Log::error('Activation key transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Activation keys have no fees
return [
'fixed_fee' => 0,
'percentage_fee' => 0,
'total_fee' => 0,
'net_amount' => $amount,
];
}
public function getSupportedCurrencies(): array
{
return ['USD']; // Activation keys are currency-agnostic
}
public function supportsRecurring(): bool
{
return false; // Activation keys are one-time
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
protected function generateUniqueActivationKey(): string
{
do {
$key = $this->config['key_prefix'].strtoupper(Str::random($this->config['key_length']));
} while (ActivationKey::where('activation_key', $key)->exists());
return $key;
}
// Public method for redeeming activation keys
public function redeemActivationKey(string $activationKey, User $user): array
{
try {
DB::beginTransaction();
$keyRecord = ActivationKey::where('activation_key', $activationKey)
->where('is_activated', false)
->firstOrFail();
// Mark key as activated and assign to user
$keyRecord->update([
'user_id' => $user->id,
'is_activated' => true,
]);
// Find or create subscription
$plan = Plan::findOrFail($keyRecord->price_id);
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'type' => 'activation_key',
'stripe_id' => 'ak_'.$keyRecord->id.'_'.uniqid(),
'stripe_status' => 'active',
'provider' => $this->getName(),
'provider_subscription_id' => $keyRecord->id,
'status' => 'active',
'starts_at' => now(),
'ends_at' => null, // No expiration for activation keys
'provider_data' => [
'activation_key' => $activationKey,
'key_id' => $keyRecord->id,
'redeemed_at' => now()->toISOString(),
],
]);
DB::commit();
return [
'success' => true,
'subscription_id' => $subscription->id,
'plan_name' => $plan->name,
'message' => 'Activation key redeemed successfully',
];
} catch (\Exception $e) {
DB::rollBack();
Log::error('Activation key redemption failed', [
'activation_key' => $activationKey,
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
$keyDetails = $this->getSubscriptionDetails($subscription->provider_subscription_id);
return $keyDetails + [
'redeemed_at' => $subscription->provider_data['redeemed_at'] ?? null,
];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
try {
$subscription->update([
'provider_data' => array_merge($subscription->provider_data ?? [], $metadata),
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to update activation key subscription metadata', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
// Activation keys don't support trials
return false;
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
// Activation keys don't support coupons
throw new \Exception('Coupons not supported for activation keys');
}
public function removeCoupon(Subscription $subscription): bool
{
return false; // No coupons to remove
}
public function getUpcomingInvoice(Subscription $subscription): array
{
// Activation keys don't have invoices
return [
'amount_due' => 0,
'currency' => 'USD',
'next_payment_date' => null,
];
}
public function retryFailedPayment(Subscription $subscription): array
{
// No payments to retry for activation keys
return $this->syncSubscriptionStatus($subscription);
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$details = $this->getSubscriptionDetails($subscription->provider_subscription_id);
return ! $details['is_activated']; // Can only modify before activation
} catch (\Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'non_refundable',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => false,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'activation_key',
'provider_subscription_id' => $subscription->provider_subscription_id,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
throw new \Exception('Import to activation keys not implemented');
}
}

View File

@@ -0,0 +1,639 @@
<?php
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class CryptoProvider implements PaymentProviderContract
{
protected array $config;
protected array $supportedCryptos = [
'BTC' => [
'name' => 'Bitcoin',
'network' => 'mainnet',
'confirmations_required' => 3,
'block_time_minutes' => 10,
],
'ETH' => [
'name' => 'Ethereum',
'network' => 'mainnet',
'confirmations_required' => 12,
'block_time_minutes' => 12,
],
'USDT' => [
'name' => 'Tether',
'network' => 'ethereum',
'confirmations_required' => 12,
'block_time_minutes' => 12,
],
'USDC' => [
'name' => 'USD Coin',
'network' => 'ethereum',
'confirmations_required' => 12,
'block_time_minutes' => 12,
],
'LTC' => [
'name' => 'Litecoin',
'network' => 'mainnet',
'confirmations_required' => 6,
'block_time_minutes' => 2.5,
],
];
public function __construct(array $config = [])
{
$defaultConfig = [
'webhook_secret' => null,
'success_url' => null,
'cancel_url' => null,
'confirmation_timeout_minutes' => 30,
'exchange_rate_provider' => 'coingecko', // or 'binance'
];
// Try to get config values if Laravel is available
try {
if (function_exists('config')) {
$defaultConfig['webhook_secret'] = config('payments.crypto.webhook_secret');
}
if (function_exists('route')) {
$defaultConfig['success_url'] = route('payment.success');
$defaultConfig['cancel_url'] = route('payment.cancel');
}
} catch (\Exception $e) {
// Laravel not available, keep defaults
}
$this->config = array_merge($defaultConfig, $config);
}
public function getName(): string
{
return 'crypto';
}
public function isActive(): bool
{
return ! empty($this->config['webhook_secret']);
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
$crypto = $options['crypto'] ?? 'BTC';
$usdAmount = $plan->price;
// Get current exchange rate
$cryptoAmount = $this->convertUsdToCrypto($usdAmount, $crypto);
// Generate payment address
$paymentAddress = $this->generatePaymentAddress($crypto);
// Create payment record
$paymentId = $this->createPaymentRecord([
'user_id' => $user->id,
'plan_id' => $plan->id,
'crypto' => $crypto,
'usd_amount' => $usdAmount,
'crypto_amount' => $cryptoAmount,
'address' => $paymentAddress,
'status' => 'pending',
'expires_at' => now()->addMinutes($this->config['confirmation_timeout_minutes']),
]);
return [
'provider_subscription_id' => $paymentId,
'status' => 'pending_payment',
'payment_address' => $paymentAddress,
'crypto' => $crypto,
'crypto_amount' => $cryptoAmount,
'usd_amount' => $usdAmount,
'expires_at' => now()->addMinutes($this->config['confirmation_timeout_minutes'])->toISOString(),
'type' => 'crypto_payment',
];
} catch (\Exception $e) {
Log::error('Crypto subscription creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
// For crypto, we just mark as cancelled since there's no external subscription
$paymentId = $subscription->provider_subscription_id;
$this->updatePaymentRecord($paymentId, [
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
return true;
} catch (\Exception $e) {
Log::error('Crypto subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
// Cancel old payment and create new one for upgraded plan
$this->cancelSubscription($subscription, 'Plan upgrade');
$user = $subscription->user;
return $this->createSubscription($user, $newPlan);
} catch (\Exception $e) {
Log::error('Crypto subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
// Crypto subscriptions don't support pausing in the traditional sense
// We could implement a temporary suspension logic here if needed
return false;
}
public function resumeSubscription(Subscription $subscription): bool
{
// Crypto subscriptions don't support pausing
return false;
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$payment = $this->getPaymentRecord($providerSubscriptionId);
return [
'id' => $payment['id'],
'status' => $payment['status'],
'crypto' => $payment['crypto'],
'usd_amount' => $payment['usd_amount'],
'crypto_amount' => $payment['crypto_amount'],
'address' => $payment['address'],
'confirmations' => $payment['confirmations'],
'created_at' => $payment['created_at'],
'expires_at' => $payment['expires_at'],
'confirmed_at' => $payment['confirmed_at'],
];
} catch (\Exception $e) {
Log::error('Crypto subscription details retrieval failed', [
'subscription_id' => $providerSubscriptionId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
return $this->createSubscription($user, $plan, $options);
}
public function createCustomerPortalSession(User $user): array
{
// Crypto doesn't have customer portals
return [
'portal_url' => route('dashboard'),
'message' => 'Crypto payments are managed through the dashboard',
];
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$webhookData = json_decode($payload, true);
if (! $this->validateWebhook($request)) {
throw new \Exception('Invalid webhook signature');
}
$eventType = $webhookData['type'] ?? 'unknown';
$result = [
'event_type' => $eventType,
'processed' => false,
'data' => [],
];
switch ($eventType) {
case 'payment_received':
$result = $this->handlePaymentReceived($webhookData);
break;
case 'payment_confirmed':
$result = $this->handlePaymentConfirmed($webhookData);
break;
case 'payment_expired':
$result = $this->handlePaymentExpired($webhookData);
break;
default:
Log::info('Unhandled crypto webhook event', ['event_type' => $eventType]);
}
return $result;
} catch (\Exception $e) {
Log::error('Crypto webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
throw $e;
}
}
public function validateWebhook(Request $request): bool
{
try {
$signature = $request->header('X-Signature');
$payload = $request->getContent();
if (! $signature || ! $this->config['webhook_secret']) {
return false;
}
$expectedSignature = hash_hmac('sha256', $payload, $this->config['webhook_secret']);
return hash_equals($signature, $expectedSignature);
} catch (\Exception $e) {
Log::warning('Crypto webhook validation failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
public function getConfiguration(): array
{
return $this->config;
}
public function syncSubscriptionStatus(Subscription $subscription): array
{
return $this->getSubscriptionDetails($subscription->provider_subscription_id);
}
public function getPaymentMethodDetails(string $paymentMethodId): array
{
try {
$payment = $this->getPaymentRecord($paymentMethodId);
return [
'id' => $payment['id'],
'type' => 'crypto_address',
'crypto' => $payment['crypto'],
'address' => $payment['address'],
'network' => $this->supportedCryptos[$payment['crypto']]['network'] ?? 'unknown',
'created_at' => $payment['created_at'],
];
} catch (\Exception $e) {
Log::error('Crypto payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $paymentId, float $amount, string $reason = ''): array
{
try {
// Crypto payments are typically not refundable
// We could implement a manual refund process if needed
throw new \Exception('Crypto payments are not refundable');
} catch (\Exception $e) {
Log::error('Crypto refund processing failed', [
'payment_id' => $paymentId,
'amount' => $amount,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$payments = $this->getUserPayments($user->id, $filters);
$transactions = [];
foreach ($payments as $payment) {
$transactions[] = [
'id' => $payment['id'],
'crypto' => $payment['crypto'],
'amount' => $payment['usd_amount'],
'crypto_amount' => $payment['crypto_amount'],
'status' => $payment['status'],
'address' => $payment['address'],
'confirmations' => $payment['confirmations'],
'created_at' => $payment['created_at'],
'confirmed_at' => $payment['confirmed_at'],
];
}
return $transactions;
} catch (\Exception $e) {
Log::error('Crypto transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Crypto fees: 1% network fee + 0.5% service fee
$networkFee = $amount * 0.01;
$serviceFee = $amount * 0.005;
$totalFee = $networkFee + $serviceFee;
return [
'fixed_fee' => 0,
'percentage_fee' => $totalFee,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
public function getSupportedCurrencies(): array
{
return ['USD']; // We accept USD but process in crypto
}
public function supportsRecurring(): bool
{
return true; // Through manual renewal
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
public function convertUsdToCrypto(float $usdAmount, string $crypto): float
{
$cacheKey = "crypto_rate_{$crypto}_usd";
return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($usdAmount, $crypto) {
$rate = $this->getExchangeRate($crypto, 'USD');
return $usdAmount / $rate;
});
}
protected function getExchangeRate(string $fromCrypto, string $toCurrency): float
{
// This would integrate with CoinGecko, Binance, or other exchange rate APIs
// For now, return mock rates
$mockRates = [
'BTC' => 45000.00, // 1 BTC = $45,000
'ETH' => 3000.00, // 1 ETH = $3,000
'USDT' => 1.00, // 1 USDT = $1.00
'USDC' => 1.00, // 1 USDC = $1.00
'LTC' => 150.00, // 1 LTC = $150
];
return $mockRates[$fromCrypto] ?? 1.0;
}
protected function generatePaymentAddress(string $crypto): string
{
// In a real implementation, this would integrate with a crypto payment processor
// For now, generate a mock address
$prefix = [
'BTC' => 'bc1q',
'ETH' => '0x',
'USDT' => '0x',
'USDC' => '0x',
'LTC' => 'ltc1',
];
$randomPart = bin2hex(random_bytes(32));
return ($prefix[$crypto] ?? '0x').substr($randomPart, 0, 40);
}
protected function createPaymentRecord(array $data): string
{
// In a real implementation, this would save to a database
// For now, generate a mock ID and cache it
$paymentId = 'crypto_'.uniqid(more_entropy: true);
Cache::put("crypto_payment_{$paymentId}", array_merge($data, [
'id' => $paymentId,
'created_at' => now()->toISOString(),
'confirmations' => 0,
]), now()->addHours(24));
return $paymentId;
}
protected function getPaymentRecord(string $paymentId): array
{
return Cache::get("crypto_payment_{$paymentId}", []);
}
protected function updatePaymentRecord(string $paymentId, array $updates): void
{
$payment = Cache::get("crypto_payment_{$paymentId}", []);
if ($payment) {
$updatedPayment = array_merge($payment, $updates);
Cache::put("crypto_payment_{$paymentId}", $updatedPayment, now()->addHours(24));
}
}
protected function getUserPayments(int $userId, array $filters = []): array
{
// In a real implementation, this would query the database
// For now, return empty array
return [];
}
protected function confirmPayment(string $paymentId, int $confirmations, string $transactionHash): void
{
$this->updatePaymentRecord($paymentId, [
'status' => 'confirmed',
'confirmations' => $confirmations,
'transaction_hash' => $transactionHash,
'confirmed_at' => now()->toISOString(),
]);
}
// Webhook handlers
protected function handlePaymentReceived(array $webhookData): array
{
$paymentId = $webhookData['payment_id'];
$confirmations = $webhookData['confirmations'] ?? 0;
$transactionHash = $webhookData['transaction_hash'] ?? '';
$this->confirmPayment($paymentId, $confirmations, $transactionHash);
return [
'event_type' => 'payment_received',
'processed' => true,
'data' => [
'payment_id' => $paymentId,
'confirmations' => $confirmations,
'transaction_hash' => $transactionHash,
],
];
}
protected function handlePaymentConfirmed(array $webhookData): array
{
$paymentId = $webhookData['payment_id'];
// Mark as fully confirmed
$this->updatePaymentRecord($paymentId, [
'status' => 'completed',
]);
return [
'event_type' => 'payment_confirmed',
'processed' => true,
'data' => [
'payment_id' => $paymentId,
],
];
}
protected function handlePaymentExpired(array $webhookData): array
{
$paymentId = $webhookData['payment_id'];
$this->updatePaymentRecord($paymentId, [
'status' => 'expired',
]);
return [
'event_type' => 'payment_expired',
'processed' => true,
'data' => [
'payment_id' => $paymentId,
],
];
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
$payment = $this->getPaymentRecord($subscription->provider_subscription_id);
return $payment['metadata'] ?? [];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
$paymentId = $subscription->provider_subscription_id;
$payment = $this->getPaymentRecord($paymentId);
if ($payment) {
$this->updatePaymentRecord($paymentId, ['metadata' => $metadata]);
return true;
}
return false;
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
// Crypto subscriptions don't have trials in the traditional sense
return false;
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
// Crypto doesn't support coupons natively
throw new \Exception('Coupons not supported for crypto payments');
}
public function removeCoupon(Subscription $subscription): bool
{
return false; // No coupons to remove
}
public function getUpcomingInvoice(Subscription $subscription): array
{
// Crypto subscriptions don't have invoices
return [
'amount_due' => 0,
'currency' => 'USD',
'next_payment_date' => null,
];
}
public function retryFailedPayment(Subscription $subscription): array
{
// Crypto payments can't be retried automatically
// User would need to make a new payment
return $this->syncSubscriptionStatus($subscription);
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$details = $this->getSubscriptionDetails($subscription->provider_subscription_id);
return in_array($details['status'], ['pending_payment', 'confirmed', 'completed']);
} catch (\Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'non_refundable',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => false,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'crypto',
'provider_subscription_id' => $subscription->provider_subscription_id,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
throw new \Exception('Import to crypto payments not implemented');
}
}

View File

@@ -0,0 +1,795 @@
<?php
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LemonSqueezyProvider implements PaymentProviderContract
{
protected array $config;
protected ?string $apiKey;
public function __construct(array $config = [])
{
$this->config = array_merge([
'api_key' => config('services.lemon_squeezy.api_key'),
'store_id' => config('services.lemon_squeezy.store_id'),
'webhook_secret' => config('services.lemon_squeezy.webhook_secret'),
'success_url' => route('payment.success'),
'cancel_url' => route('payment.cancel'),
'api_version' => 'v1',
], $config);
$this->apiKey = $this->config['api_key'] ?? null;
}
public function getName(): string
{
return 'lemon_squeezy';
}
public function isActive(): bool
{
return ! empty($this->apiKey) && ! empty($this->config['store_id']);
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
$variantId = $this->getOrCreateVariant($plan);
$checkoutData = [
'store_id' => $this->config['store_id'],
'variant_id' => $variantId,
'customer_email' => $user->email,
'success_url' => $options['success_url'] ?? $this->config['success_url'],
'cancel_url' => $options['cancel_url'] ?? $this->config['cancel_url'],
'embed' => false,
'invoice_grace_period' => 0,
];
if (! empty($options['trial_days'])) {
$checkoutData['trial_period'] = $options['trial_days'];
}
if (! empty($options['coupon_code'])) {
$checkoutData['discount_code'] = $options['coupon_code'];
}
// Add custom data for tracking
$checkoutData['custom_data'] = [
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => 'lemon_squeezy',
];
$response = $this->makeRequest('POST', '/checkouts', $checkoutData);
return [
'provider_subscription_id' => $response['data']['id'],
'status' => 'pending',
'checkout_url' => $response['data']['attributes']['url'],
'type' => 'checkout_session',
];
} catch (\Exception $e) {
Log::error('Lemon Squeezy subscription creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
$subscriptionId = $subscription->provider_subscription_id;
if (! $subscriptionId) {
throw new \Exception('No Lemon Squeezy subscription ID found');
}
// Cancel at period end (graceful cancellation)
$response = $this->makeRequest('DELETE', "/subscriptions/{$subscriptionId}", [
'cancel_at_period_end' => true,
]);
return true;
} catch (\Exception $e) {
Log::error('Lemon Squeezy subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
$subscriptionId = $subscription->provider_subscription_id;
$newVariantId = $this->getOrCreateVariant($newPlan);
// Update subscription variant
$response = $this->makeRequest('PATCH', "/subscriptions/{$subscriptionId}", [
'variant_id' => $newVariantId,
]);
return [
'provider_subscription_id' => $subscriptionId,
'status' => $response['data']['attributes']['status'],
'new_variant_id' => $newVariantId,
];
} catch (\Exception $e) {
Log::error('Lemon Squeezy subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
try {
$subscriptionId = $subscription->provider_subscription_id;
// Pause subscription
$response = $this->makeRequest('PATCH', "/subscriptions/{$subscriptionId}", [
'pause' => [
'mode' => 'void',
],
]);
return true;
} catch (\Exception $e) {
Log::error('Lemon Squeezy subscription pause failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function resumeSubscription(Subscription $subscription): bool
{
try {
$subscriptionId = $subscription->provider_subscription_id;
// Unpause subscription
$response = $this->makeRequest('PATCH', "/subscriptions/{$subscriptionId}", [
'pause' => null,
'cancel_at_period_end' => false,
]);
return true;
} catch (\Exception $e) {
Log::error('Lemon Squeezy subscription resume failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$response = $this->makeRequest('GET', "/subscriptions/{$providerSubscriptionId}");
$data = $response['data']['attributes'];
return [
'id' => $data['id'],
'status' => $data['status'],
'customer_id' => $data['customer_id'],
'order_id' => $data['order_id'],
'product_id' => $data['product_id'],
'variant_id' => $data['variant_id'],
'created_at' => $data['created_at'],
'updated_at' => $data['updated_at'],
'trial_ends_at' => $data['trial_ends_at'] ?? null,
'renews_at' => $data['renews_at'] ?? null,
'ends_at' => $data['ends_at'] ?? null,
'cancelled_at' => $data['cancelled_at'] ?? null,
];
} catch (\Exception $e) {
Log::error('Lemon Squeezy subscription details retrieval failed', [
'subscription_id' => $providerSubscriptionId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
return $this->createSubscription($user, $plan, $options);
}
public function createCustomerPortalSession(User $user): array
{
try {
// Lemon Squeezy doesn't have a customer portal like Stripe
// Instead, we can redirect to the customer's orders page
return [
'portal_url' => 'https://app.lemonsqueezy.com/my-orders',
'message' => 'Lemon Squeezy customer portal',
];
} catch (\Exception $e) {
Log::error('Lemon Squeezy customer portal creation failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$eventData = json_decode($payload, true);
$eventType = $eventData['meta']['event_name'] ?? 'unknown';
$result = [
'event_type' => $eventType,
'processed' => false,
'data' => [],
];
switch ($eventType) {
case 'subscription_created':
$result = $this->handleSubscriptionCreated($eventData);
break;
case 'subscription_updated':
$result = $this->handleSubscriptionUpdated($eventData);
break;
case 'subscription_cancelled':
$result = $this->handleSubscriptionCancelled($eventData);
break;
case 'subscription_resumed':
$result = $this->handleSubscriptionResumed($eventData);
break;
case 'order_created':
$result = $this->handleOrderCreated($eventData);
break;
case 'order_payment_succeeded':
$result = $this->handleOrderPaymentSucceeded($eventData);
break;
default:
Log::info('Unhandled Lemon Squeezy webhook event', ['event_type' => $eventType]);
}
return $result;
} catch (\Exception $e) {
Log::error('Lemon Squeezy webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
throw $e;
}
}
public function validateWebhook(Request $request): bool
{
try {
$signature = $request->header('X-Signature');
$payload = $request->getContent();
if (! $signature || ! $this->config['webhook_secret']) {
return false;
}
$expectedSignature = hash_hmac('sha256', $payload, $this->config['webhook_secret']);
return hash_equals($signature, $expectedSignature);
} catch (\Exception $e) {
Log::warning('Lemon Squeezy webhook validation failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
public function getConfiguration(): array
{
return $this->config;
}
public function syncSubscriptionStatus(Subscription $subscription): array
{
return $this->getSubscriptionDetails($subscription->provider_subscription_id);
}
public function getPaymentMethodDetails(string $paymentMethodId): array
{
try {
$response = $this->makeRequest('GET', "/payment-methods/{$paymentMethodId}");
$data = $response['data']['attributes'];
return [
'id' => $data['id'],
'type' => $data['type'],
'card' => [
'last4' => $data['last4'] ?? null,
'brand' => $data['brand'] ?? null,
'exp_month' => $data['exp_month'] ?? null,
'exp_year' => $data['exp_year'] ?? null,
],
'created_at' => $data['created_at'],
];
} catch (\Exception $e) {
Log::error('Lemon Squeezy payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $orderId, float $amount, string $reason = ''): array
{
try {
$response = $this->makeRequest('POST', "/orders/{$orderId}/refunds", [
'amount' => (int) ($amount * 100), // Lemon Squeezy uses cents
'reason' => $reason ?: 'requested_by_customer',
'note' => 'Refund processed via unified payment system',
]);
return [
'refund_id' => $response['data']['id'],
'amount' => $response['data']['attributes']['amount'] / 100,
'status' => $response['data']['attributes']['status'],
'created_at' => $response['data']['attributes']['created_at'],
];
} catch (\Exception $e) {
Log::error('Lemon Squeezy refund processing failed', [
'order_id' => $orderId,
'amount' => $amount,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
// Get all orders for the customer
$response = $this->makeRequest('GET', '/orders', [
'filter' => [
'customer_email' => $user->email,
],
'page' => [
'limit' => $filters['limit'] ?? 100,
],
]);
$transactions = [];
foreach ($response['data'] as $order) {
$attributes = $order['attributes'];
$transactions[] = [
'id' => $attributes['id'],
'order_number' => $attributes['order_number'],
'amount' => $attributes['total'] / 100,
'currency' => $attributes['currency'],
'status' => $attributes['status'],
'created_at' => $attributes['created_at'],
'refunded' => $attributes['refunded'] ?? false,
'customer_email' => $attributes['customer_email'],
];
}
return $transactions;
} catch (\Exception $e) {
Log::error('Lemon Squeezy transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Lemon Squeezy fees: 5% + $0.50 flat fee
$fixedFee = 0.50;
$percentageFee = 5.0;
$percentageAmount = ($amount * $percentageFee) / 100;
$totalFee = $fixedFee + $percentageAmount;
return [
'fixed_fee' => $fixedFee,
'percentage_fee' => $percentageAmount,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
public function getSupportedCurrencies(): array
{
return [
'USD', 'EUR', 'GBP', 'CAD', 'AUD', 'CHF', 'SEK', 'NOK', 'DKK',
'PLN', 'CZK', 'HUF', 'RON', 'BGN', 'HRK', 'RUB', 'TRY', 'MXN',
'BRL', 'ARS', 'CLP', 'COP', 'PEN', 'UYU', 'JPY', 'SGD', 'HKD',
'INR', 'MYR', 'THB', 'PHP', 'TWD', 'KRW', 'CNY', 'NZD',
'ZAR', 'NGN', 'KES', 'GHS', 'EGP', 'MAD', 'TND', 'DZD',
];
}
public function supportsRecurring(): bool
{
return true;
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
protected function makeRequest(string $method, string $endpoint, array $data = []): array
{
$url = "https://api.lemonsqueezy.com/{$this->config['api_version']}{$endpoint}";
$headers = [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'Authorization' => 'Bearer '.$this->apiKey,
];
$response = Http::withHeaders($headers)
->asJson()
->send($method, $url, $data);
if (! $response->successful()) {
throw new \Exception("Lemon Squeezy API request failed: {$response->status()} - {$response->body()}");
}
return $response->json();
}
protected function getOrCreateVariant(Plan $plan): string
{
// Check if plan already has a Lemon Squeezy variant ID
if (! empty($plan->details['lemon_squeezy_variant_id'])) {
return $plan->details['lemon_squeezy_variant_id'];
}
// Create product if it doesn't exist
$productId = $this->getOrCreateProduct($plan);
// Create variant
$variantData = [
'product_id' => $productId,
'name' => $plan->name,
'description' => $plan->description ?? '',
'price' => $plan->price * 100, // Convert to cents
'price_formatted' => $this->formatPrice($plan->price),
];
if ($plan->monthly_billing) {
$variantData['interval'] = 'month';
$variantData['interval_count'] = 1;
} else {
$variantData['interval'] = 'one_time';
}
$response = $this->makeRequest('POST', '/variants', $variantData);
$variantId = $response['data']['id'];
// Update plan with new variant ID
$planDetails = $plan->details ?? [];
$planDetails['lemon_squeezy_variant_id'] = $variantId;
$plan->update(['details' => $planDetails]);
return $variantId;
}
protected function getOrCreateProduct(Plan $plan): string
{
// Check if plan already has a Lemon Squeezy product ID
if (! empty($plan->details['lemon_squeezy_product_id'])) {
return $plan->details['lemon_squeezy_product_id'];
}
// Create product
$productData = [
'store_id' => $this->config['store_id'],
'name' => $plan->name,
'description' => $plan->description ?? '',
'slug' => strtolower(str_replace(' ', '-', $plan->name)),
];
$response = $this->makeRequest('POST', '/products', $productData);
$productId = $response['data']['id'];
// Update plan with new product ID
$planDetails = $plan->details ?? [];
$planDetails['lemon_squeezy_product_id'] = $productId;
$plan->update(['details' => $planDetails]);
return $productId;
}
protected function formatPrice(float $price): string
{
// Format price based on currency
$currency = $this->config['currency'] ?? 'USD';
switch ($currency) {
case 'USD':
case 'CAD':
case 'AUD':
return '$'.number_format($price, 2);
case 'EUR':
return '€'.number_format($price, 2);
case 'GBP':
return '£'.number_format($price, 2);
default:
return number_format($price, 2).' '.$currency;
}
}
// Webhook handlers
protected function handleSubscriptionCreated(array $eventData): array
{
$attributes = $eventData['data']['attributes'];
return [
'event_type' => $eventData['meta']['event_name'],
'processed' => true,
'data' => [
'subscription_id' => $attributes['id'],
'customer_id' => $attributes['customer_id'],
'product_id' => $attributes['product_id'],
'variant_id' => $attributes['variant_id'],
'status' => $attributes['status'],
],
];
}
protected function handleSubscriptionUpdated(array $eventData): array
{
$attributes = $eventData['data']['attributes'];
return [
'event_type' => $eventData['meta']['event_name'],
'processed' => true,
'data' => [
'subscription_id' => $attributes['id'],
'status' => $attributes['status'],
'renews_at' => $attributes['renews_at'] ?? null,
],
];
}
protected function handleSubscriptionCancelled(array $eventData): array
{
$attributes = $eventData['data']['attributes'];
return [
'event_type' => $eventData['meta']['event_name'],
'processed' => true,
'data' => [
'subscription_id' => $attributes['id'],
'status' => 'cancelled',
'cancelled_at' => $attributes['cancelled_at'],
],
];
}
protected function handleSubscriptionResumed(array $eventData): array
{
$attributes = $eventData['data']['attributes'];
return [
'event_type' => $eventData['meta']['event_name'],
'processed' => true,
'data' => [
'subscription_id' => $attributes['id'],
'status' => $attributes['status'],
],
];
}
protected function handleOrderCreated(array $eventData): array
{
$attributes = $eventData['data']['attributes'];
return [
'event_type' => $eventData['meta']['event_name'],
'processed' => true,
'data' => [
'order_id' => $attributes['id'],
'order_number' => $attributes['order_number'],
'customer_email' => $attributes['customer_email'],
'total' => $attributes['total'],
'currency' => $attributes['currency'],
],
];
}
protected function handleOrderPaymentSucceeded(array $eventData): array
{
$attributes = $eventData['data']['attributes'];
return [
'event_type' => $eventData['meta']['event_name'],
'processed' => true,
'data' => [
'order_id' => $attributes['id'],
'order_number' => $attributes['order_number'],
'total' => $attributes['total'],
'status' => 'paid',
],
];
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
return $subscription->provider_data['metadata'] ?? [];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
// Lemon Squeezy doesn't support metadata on subscriptions directly
// Store in our local provider_data instead
$providerData = $subscription->provider_data ?? [];
$providerData['metadata'] = $metadata;
$subscription->update(['provider_data' => $providerData]);
return true;
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
// Lemon Squeezy handles trials via variant configuration
// This would require creating a trial variant and switching
return true;
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
try {
// Apply discount code to subscription
$subscriptionId = $subscription->provider_subscription_id;
$response = $this->makeRequest('POST', "/subscriptions/{$subscriptionId}/discounts", [
'discount_code' => $couponCode,
]);
return [
'discount_id' => $response['data']['id'],
'amount' => $response['data']['attributes']['amount'] / 100,
'type' => $response['data']['attributes']['type'],
];
} catch (\Exception $e) {
Log::error('Failed to apply Lemon Squeezy coupon', [
'subscription_id' => $subscription->id,
'coupon_code' => $couponCode,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function removeCoupon(Subscription $subscription): bool
{
try {
$subscriptionId = $subscription->provider_subscription_id;
// Get and delete all discounts
$discounts = $this->makeRequest('GET', "/subscriptions/{$subscriptionId}/discounts");
foreach ($discounts['data'] as $discount) {
$this->makeRequest('DELETE', "/discounts/{$discount['id']}");
}
return true;
} catch (\Exception $e) {
Log::error('Failed to remove Lemon Squeezy coupon', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function getUpcomingInvoice(Subscription $subscription): array
{
try {
$subscriptionId = $subscription->provider_subscription_id;
$response = $this->makeRequest('GET', "/subscriptions/{$subscriptionId}");
$attributes = $response['data']['attributes'];
return [
'amount_due' => $attributes['renews_at'] ? 0 : $attributes['subtotal'] / 100,
'currency' => $attributes['currency'],
'next_payment_date' => $attributes['renews_at'],
];
} catch (\Exception $e) {
Log::error('Failed to get Lemon Squeezy upcoming invoice', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function retryFailedPayment(Subscription $subscription): array
{
// Lemon Squeezy handles failed payments automatically
// We can trigger a subscription sync instead
return $this->syncSubscriptionStatus($subscription);
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$details = $this->getSubscriptionDetails($subscription->provider_subscription_id);
return in_array($details['status'], ['active', 'trialing', 'paused']);
} catch (\Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => false, // Lemon Squeezy cancels at period end
'refund_policy' => 'as_per_terms',
'cancellation_effective' => 'period_end',
'billing_cycle_proration' => true,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'lemon_squeezy',
'provider_subscription_id' => $subscription->provider_subscription_id,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
// Import to Lemon Squeezy - would require creating matching products/variants
throw new \Exception('Import to Lemon Squeezy not implemented');
}
}

View File

@@ -0,0 +1,383 @@
<?php
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class OxapayProvider implements PaymentProviderContract
{
protected array $config;
protected string $baseUrl;
public function __construct(array $config = [])
{
$this->config = $config;
$this->baseUrl = $config['sandbox'] ?? false
? 'https://api-sandbox.oxapay.com/v1'
: 'https://api.oxapay.com/v1';
}
public function getName(): string
{
return 'oxapay';
}
public function isActive(): bool
{
return ! empty($this->config['merchant_api_key']);
}
public function supportsRecurring(): bool
{
return false; // OxaPay doesn't support recurring payments
}
public function supportsOneTime(): bool
{
return true;
}
public function getSupportedCurrencies(): array
{
return Cache::remember('oxapay_currencies', now()->addHour(), function () {
try {
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
])->get("{$this->baseUrl}/info/currencies");
if ($response->successful()) {
$data = $response->json();
return $data['data'] ?? [];
}
} catch (\Exception $e) {
Log::error('Failed to fetch OxaPay currencies', [
'error' => $e->getMessage(),
]);
}
return ['BTC', 'ETH', 'USDT', 'USDC', 'LTC', 'BCH']; // Default common cryptos
});
}
public function calculateFees(float $amount): array
{
// OxaPay fees vary by currency and network
// Using average estimates - actual fees should be fetched from API
$percentageFee = 0.5; // 0.5% average
$fixedFee = 0.0; // No fixed fee for most cryptos
$totalFee = ($amount * $percentageFee / 100) + $fixedFee;
return [
'fixed_fee' => $fixedFee,
'percentage_fee' => $percentageFee,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function pauseSubscription(Subscription $subscription): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function resumeSubscription(Subscription $subscription): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function getSubscriptionDetails(string $subscriptionId): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
try {
$amount = $options['amount'] ?? $plan->price;
$currency = $options['currency'] ?? 'USD';
$toCurrency = $options['to_currency'] ?? 'USDT';
$payload = [
'amount' => $amount,
'currency' => $currency,
'to_currency' => $toCurrency,
'lifetime' => $options['lifetime'] ?? 60, // 60 minutes default
'fee_paid_by_payer' => $options['fee_paid_by_payer'] ?? 0,
'callback_url' => $this->config['webhook_url'] ?? route('webhooks.oxapay'),
'return_url' => $this->config['success_url'] ?? route('payment.success'),
'email' => $user->email,
'order_id' => $options['order_id'] ?? null,
'description' => $options['description'] ?? "Payment for {$plan->name}",
'sandbox' => $this->config['sandbox'] ?? false,
];
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/payment/invoice", $payload);
if (! $response->successful()) {
throw new \Exception('Failed to create OxaPay invoice: '.$response->body());
}
$data = $response->json();
return [
'success' => true,
'checkout_url' => $data['data']['payment_url'] ?? null,
'payment_id' => $data['data']['track_id'] ?? null,
'expires_at' => $data['data']['expired_at'] ?? null,
'amount' => $amount,
'currency' => $currency,
'provider' => 'oxapay',
];
} catch (\Exception $e) {
Log::error('OxaPay checkout session creation failed', [
'error' => $e->getMessage(),
'user_id' => $user->id,
'plan_id' => $plan->id,
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
public function createCustomerPortalSession(User $user): array
{
throw new \Exception('OxaPay does not provide customer portal functionality');
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$signature = $request->header('HMAC');
if (! $this->validateWebhook($request)) {
throw new \Exception('Invalid webhook signature');
}
$data = $request->json()->all();
$status = $data['status'] ?? 'unknown';
$trackId = $data['track_id'] ?? null;
$type = $data['type'] ?? 'payment';
return [
'success' => true,
'event_type' => $status,
'provider_transaction_id' => $trackId,
'processed' => true,
'data' => $data,
'type' => $type,
];
} catch (\Exception $e) {
Log::error('OxaPay webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
return [
'success' => false,
'event_type' => 'error',
'provider_transaction_id' => null,
'processed' => false,
'error' => $e->getMessage(),
];
}
}
public function validateWebhook(Request $request): bool
{
try {
$payload = $request->getContent();
$signature = $request->header('HMAC');
$apiSecret = $this->config['merchant_api_key'];
if (empty($signature) || empty($apiSecret)) {
return false;
}
$expectedSignature = hash_hmac('sha512', $payload, $apiSecret);
return hash_equals($expectedSignature, $signature);
} catch (\Exception $e) {
Log::error('OxaPay webhook validation failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
public function getConfiguration(): array
{
return $this->config;
}
public function syncSubscriptionStatus(Subscription $subscription): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function getPaymentMethodDetails(string $paymentId): array
{
try {
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
])->get("{$this->baseUrl}/payment/info", [
'track_id' => $paymentId,
]);
if ($response->successful()) {
$data = $response->json();
return [
'success' => true,
'details' => $data['data'] ?? [],
];
}
return [
'success' => false,
'error' => 'Payment not found',
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
public function processRefund(string $paymentIntentId, float $amount, string $reason = ''): array
{
// OxaPay doesn't support traditional refunds in crypto
// Would need manual payout process
return [
'success' => false,
'error' => 'OxaPay refunds must be processed manually via payouts',
];
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
])->get("{$this->baseUrl}/payment/history", array_merge([
'email' => $user->email,
], $filters));
if ($response->successful()) {
$data = $response->json();
return [
'success' => true,
'transactions' => $data['data'] ?? [],
];
}
return [
'success' => false,
'error' => 'Failed to fetch transaction history',
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
public function getSubscriptionMetadata(Subscription $subscription): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function removeCoupon(Subscription $subscription): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function getUpcomingInvoice(Subscription $subscription): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function retryFailedPayment(Subscription $subscription): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function canModifySubscription(Subscription $subscription): bool
{
return false; // No recurring support
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'no_refunds_crypto',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => false,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
}

View File

@@ -0,0 +1,985 @@
<?php
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PolarProvider implements PaymentProviderContract
{
protected array $config;
protected string $apiBaseUrl = 'https://api.polar.sh';
public function __construct(array $config = [])
{
$this->config = array_merge([
'api_key' => config('services.polar.api_key'),
'webhook_secret' => config('services.polar.webhook_secret'),
'success_url' => route('payment.success'),
'cancel_url' => route('payment.cancel'),
'webhook_url' => route('webhook.payment', 'polar'),
], $config);
}
public function getName(): string
{
return 'polar';
}
public function isActive(): bool
{
return ! empty($this->config['api_key']);
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
// Get or create Polar customer
$customer = $this->getOrCreateCustomer($user);
// Get or create Polar product/price
$priceId = $this->getOrCreatePrice($plan);
// Create checkout session
$checkoutData = [
'customer_id' => $customer['id'],
'price_id' => $priceId,
'success_url' => $this->config['success_url'],
'cancel_url' => $this->config['cancel_url'],
'customer_email' => $user->email,
'customer_name' => $user->name,
'metadata' => [
'user_id' => $user->id,
'plan_id' => $plan->id,
'plan_name' => $plan->name,
],
];
// Add trial information if specified
if (isset($options['trial_days']) && $options['trial_days'] > 0) {
$checkoutData['trial_period_days'] = $options['trial_days'];
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/checkouts', $checkoutData);
if (! $response->successful()) {
throw new \Exception('Polar checkout creation failed: '.$response->body());
}
$checkout = $response->json();
// Create subscription record
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'type' => 'recurring',
'stripe_id' => $checkout['id'], // Using stripe_id field for Polar checkout ID
'stripe_status' => 'pending',
'provider' => $this->getName(),
'provider_subscription_id' => $checkout['id'],
'status' => 'pending_payment',
'starts_at' => null,
'ends_at' => null,
'provider_data' => [
'checkout_id' => $checkout['id'],
'checkout_url' => $checkout['url'],
'customer_id' => $customer['id'],
'price_id' => $priceId,
'created_at' => now()->toISOString(),
],
]);
return [
'provider_subscription_id' => $checkout['id'],
'status' => 'pending_payment',
'checkout_url' => $checkout['url'],
'customer_id' => $customer['id'],
'price_id' => $priceId,
'type' => 'polar_checkout',
'expires_at' => $checkout['expires_at'] ?? now()->addHours(24)->toISOString(),
];
} catch (\Exception $e) {
Log::error('Polar subscription creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
// Local cancellation only
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
return true;
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->delete($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId, [
'cancellation_reason' => $reason,
]);
if (! $response->successful()) {
throw new \Exception('Polar subscription cancellation failed: '.$response->body());
}
// Update local subscription
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
return true;
} catch (\Exception $e) {
Log::error('Polar subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
throw new \Exception('No Polar subscription found to update');
}
$newPriceId = $this->getOrCreatePrice($newPlan);
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->patch($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId, [
'price_id' => $newPriceId,
'proration_behavior' => 'create_prorations',
]);
if (! $response->successful()) {
throw new \Exception('Polar subscription update failed: '.$response->body());
}
$updatedSubscription = $response->json();
// Update local subscription
$subscription->update([
'plan_id' => $newPlan->id,
'provider_data' => array_merge($subscription->provider_data ?? [], [
'updated_at' => now()->toISOString(),
'polar_subscription' => $updatedSubscription,
]),
]);
return [
'provider_subscription_id' => $updatedSubscription['id'],
'status' => $updatedSubscription['status'],
'price_id' => $newPriceId,
'updated_at' => $updatedSubscription['updated_at'],
];
} catch (\Exception $e) {
Log::error('Polar subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
return false;
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/pause');
if (! $response->successful()) {
throw new \Exception('Polar subscription pause failed: '.$response->body());
}
$subscription->update([
'status' => 'paused',
'paused_at' => now(),
]);
return true;
} catch (\Exception $e) {
Log::error('Polar subscription pause failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function resumeSubscription(Subscription $subscription): bool
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
return false;
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/resume');
if (! $response->successful()) {
throw new \Exception('Polar subscription resume failed: '.$response->body());
}
$subscription->update([
'status' => 'active',
'resumed_at' => now(),
]);
return true;
} catch (\Exception $e) {
Log::error('Polar subscription resume failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/subscriptions/'.$providerSubscriptionId);
if (! $response->successful()) {
throw new \Exception('Failed to retrieve Polar subscription: '.$response->body());
}
$polarSubscription = $response->json();
return [
'id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
'customer_id' => $polarSubscription['customer_id'],
'price_id' => $polarSubscription['price_id'],
'current_period_start' => $polarSubscription['current_period_start'],
'current_period_end' => $polarSubscription['current_period_end'],
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
'trial_start' => $polarSubscription['trial_start'] ?? null,
'trial_end' => $polarSubscription['trial_end'] ?? null,
'created_at' => $polarSubscription['created_at'],
'updated_at' => $polarSubscription['updated_at'],
];
} catch (\Exception $e) {
Log::error('Polar subscription details retrieval failed', [
'subscription_id' => $providerSubscriptionId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
return $this->createSubscription($user, $plan, $options);
}
public function createCustomerPortalSession(User $user): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/customer-portal', [
'customer_id' => $customer['id'],
'return_url' => route('dashboard'),
]);
if (! $response->successful()) {
throw new \Exception('Polar customer portal creation failed: '.$response->body());
}
$portal = $response->json();
return [
'portal_url' => $portal['url'],
'customer_id' => $customer['id'],
];
} catch (\Exception $e) {
Log::error('Polar customer portal creation failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$signature = $request->header('Polar-Signature');
if (! $this->validateWebhook($request)) {
throw new \Exception('Invalid Polar webhook signature');
}
$webhookData = json_decode($payload, true);
$eventType = $webhookData['type'] ?? 'unknown';
$result = [
'event_type' => $eventType,
'processed' => false,
'data' => [],
];
switch ($eventType) {
case 'checkout.created':
$result = $this->handleCheckoutCreated($webhookData);
break;
case 'subscription.created':
$result = $this->handleSubscriptionCreated($webhookData);
break;
case 'subscription.updated':
$result = $this->handleSubscriptionUpdated($webhookData);
break;
case 'subscription.cancelled':
$result = $this->handleSubscriptionCancelled($webhookData);
break;
case 'subscription.paused':
$result = $this->handleSubscriptionPaused($webhookData);
break;
case 'subscription.resumed':
$result = $this->handleSubscriptionResumed($webhookData);
break;
default:
Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]);
}
return $result;
} catch (\Exception $e) {
Log::error('Polar webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
throw $e;
}
}
public function validateWebhook(Request $request): bool
{
try {
$signature = $request->header('Polar-Signature');
$payload = $request->getContent();
if (! $signature || ! $this->config['webhook_secret']) {
return false;
}
$expectedSignature = hash_hmac('sha256', $payload, $this->config['webhook_secret']);
return hash_equals($signature, $expectedSignature);
} catch (\Exception $e) {
Log::warning('Polar webhook validation failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
public function getConfiguration(): array
{
return $this->config;
}
public function syncSubscriptionStatus(Subscription $subscription): array
{
return $this->getSubscriptionDetails($subscription->provider_subscription_id);
}
public function getPaymentMethodDetails(string $paymentMethodId): array
{
try {
// Polar doesn't have separate payment method IDs like Stripe
// Return subscription details instead
return $this->getSubscriptionDetails($paymentMethodId);
} catch (\Exception $e) {
Log::error('Polar payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $paymentId, float $amount, string $reason = ''): array
{
try {
// Polar handles refunds through their dashboard or API
// For now, we'll return a NotImplementedError
throw new \Exception('Polar refunds must be processed through Polar dashboard or API directly');
} catch (\Exception $e) {
Log::error('Polar refund processing failed', [
'payment_id' => $paymentId,
'amount' => $amount,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$params = [
'customer_id' => $customer['id'],
'limit' => $filters['limit'] ?? 50,
];
if (isset($filters['start_date'])) {
$params['start_date'] = $filters['start_date'];
}
if (isset($filters['end_date'])) {
$params['end_date'] = $filters['end_date'];
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/subscriptions', $params);
if (! $response->successful()) {
throw new \Exception('Failed to retrieve Polar transaction history: '.$response->body());
}
$polarSubscriptions = $response->json();
$transactions = [];
foreach ($polarSubscriptions['data'] ?? [] as $subscription) {
$transactions[] = [
'id' => $subscription['id'],
'status' => $subscription['status'],
'amount' => $subscription['amount'] ?? 0,
'currency' => $subscription['currency'] ?? 'USD',
'created_at' => $subscription['created_at'],
'current_period_start' => $subscription['current_period_start'],
'current_period_end' => $subscription['current_period_end'],
];
}
return $transactions;
} catch (\Exception $e) {
Log::error('Polar transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Polar fees vary by plan and region (typically 5-8%)
// Using 6% as default for calculation
$percentageFee = $amount * 0.06;
$totalFee = $percentageFee; // Polar typically doesn't have fixed fees
return [
'fixed_fee' => 0,
'percentage_fee' => $percentageFee,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
public function getSupportedCurrencies(): array
{
return ['USD']; // Polar supports USD, EUR, and other currencies, but USD is most common
}
public function supportsRecurring(): bool
{
return true;
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
protected function getOrCreateCustomer(User $user): array
{
// First, try to find existing customer by email
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/customers', [
'email' => $user->email,
]);
if ($response->successful() && ! empty($response->json()['data'])) {
return $response->json()['data'][0];
}
// Create new customer
$customerData = [
'email' => $user->email,
'name' => $user->name,
'metadata' => [
'user_id' => $user->id,
],
];
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/customers', $customerData);
if (! $response->successful()) {
throw new \Exception('Failed to create Polar customer: '.$response->body());
}
return $response->json();
}
protected function getOrCreatePrice(Plan $plan): string
{
// Look for existing price by plan metadata
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/products', [
'metadata[plan_id]' => $plan->id,
]);
if ($response->successful() && ! empty($response->json()['data'])) {
$product = $response->json()['data'][0];
// Get the price for this product
$priceResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/prices', [
'product_id' => $product['id'],
'recurring_interval' => 'month',
]);
if ($priceResponse->successful() && ! empty($priceResponse->json()['data'])) {
return $priceResponse->json()['data'][0]['id'];
}
}
// Create new product and price
$productData = [
'name' => $plan->name,
'description' => $plan->description ?? 'Subscription plan',
'type' => 'service',
'metadata' => [
'plan_id' => $plan->id,
'plan_name' => $plan->name,
],
];
$productResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/products', $productData);
if (! $productResponse->successful()) {
throw new \Exception('Failed to create Polar product: '.$productResponse->body());
}
$product = $productResponse->json();
// Create price for the product
$priceData = [
'product_id' => $product['id'],
'amount' => (int) ($plan->price * 100), // Convert to cents
'currency' => 'usd',
'recurring' => [
'interval' => 'month',
'interval_count' => 1,
],
];
$priceResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/prices', $priceData);
if (! $priceResponse->successful()) {
throw new \Exception('Failed to create Polar price: '.$priceResponse->body());
}
$price = $priceResponse->json();
return $price['id'];
}
protected function getPolarSubscriptionId(Subscription $subscription): ?string
{
$providerData = $subscription->provider_data ?? [];
return $providerData['polar_subscription']['id'] ?? null;
}
// Webhook handlers
protected function handleCheckoutCreated(array $webhookData): array
{
$checkout = $webhookData['data']['object'];
// Update local subscription with checkout ID
Subscription::where('stripe_id', $checkout['id'])->update([
'provider_data' => [
'checkout_id' => $checkout['id'],
'customer_id' => $checkout['customer_id'],
'polar_checkout' => $checkout,
],
]);
return [
'event_type' => 'checkout.created',
'processed' => true,
'data' => [
'checkout_id' => $checkout['id'],
'customer_id' => $checkout['customer_id'],
],
];
}
protected function handleSubscriptionCreated(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
// Find and update local subscription
$localSubscription = Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['checkout_id'])
->first();
if ($localSubscription) {
$localSubscription->update([
'stripe_id' => $polarSubscription['id'],
'provider_subscription_id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
'starts_at' => Carbon::parse($polarSubscription['current_period_start']),
'ends_at' => Carbon::parse($polarSubscription['current_period_end']),
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
'polar_subscription' => $polarSubscription,
'activated_at' => now()->toISOString(),
]),
]);
}
return [
'event_type' => 'subscription.created',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
],
];
}
protected function handleSubscriptionUpdated(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => $polarSubscription['status'],
'provider_data' => [
'polar_subscription' => $polarSubscription,
'updated_at' => now()->toISOString(),
],
]);
return [
'event_type' => 'subscription.updated',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
],
];
}
protected function handleSubscriptionCancelled(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => 'Polar webhook cancellation',
]);
return [
'event_type' => 'subscription.cancelled',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
],
];
}
protected function handleSubscriptionPaused(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => 'paused',
'paused_at' => now(),
]);
return [
'event_type' => 'subscription.paused',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
],
];
}
protected function handleSubscriptionResumed(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => 'active',
'resumed_at' => now(),
]);
return [
'event_type' => 'subscription.resumed',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
],
];
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
return $subscription->provider_data['polar_subscription'] ?? [];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
try {
$subscription->update([
'provider_data' => array_merge($subscription->provider_data ?? [], [
'metadata' => $metadata,
]),
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to update Polar subscription metadata', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
// Polar handles trials through checkout creation
// This would require creating a new checkout with trial period
return false;
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
// Polar supports discount codes
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
throw new \Exception('No Polar subscription found');
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/discount', [
'coupon_code' => $couponCode,
]);
if (! $response->successful()) {
throw new \Exception('Failed to apply Polar coupon: '.$response->body());
}
return $response->json();
} catch (\Exception $e) {
Log::error('Polar coupon application failed', [
'subscription_id' => $subscription->id,
'coupon_code' => $couponCode,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function removeCoupon(Subscription $subscription): bool
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
return false;
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->delete($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/discount');
return $response->successful();
} catch (\Exception $e) {
Log::error('Polar coupon removal failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function getUpcomingInvoice(Subscription $subscription): array
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
return [
'amount_due' => 0,
'currency' => 'USD',
'next_payment_date' => null,
];
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice');
if (! $response->successful()) {
throw new \Exception('Failed to retrieve Polar upcoming invoice: '.$response->body());
}
$invoice = $response->json();
return [
'amount_due' => $invoice['amount_due'] / 100, // Convert from cents
'currency' => $invoice['currency'],
'next_payment_date' => $invoice['next_payment_date'],
];
} catch (\Exception $e) {
Log::error('Polar upcoming invoice retrieval failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function retryFailedPayment(Subscription $subscription): array
{
// Polar doesn't have explicit retry logic - payments are retried automatically
return $this->syncSubscriptionStatus($subscription);
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$details = $this->getSubscriptionDetails($subscription->provider_subscription_id);
return in_array($details['status'], ['active', 'trialing']);
} catch (\Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'no_pro_rated_refunds',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => false,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'polar',
'provider_subscription_id' => $subscription->provider_subscription_id,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
throw new \Exception('Import to Polar payments not implemented');
}
}

View File

@@ -0,0 +1,838 @@
<?php
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Subscription as CashierSubscription;
use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Price;
use Stripe\Product;
use Stripe\Stripe;
class StripeProvider implements PaymentProviderContract
{
protected array $config;
protected ?string $apiKey;
public function __construct(array $config = [])
{
$this->config = array_merge([
'api_key' => config('services.stripe.secret'),
'webhook_secret' => config('services.stripe.webhook_secret'),
'currency' => config('cashier.currency', 'USD'),
'success_url' => route('payment.success'),
'cancel_url' => route('payment.cancel'),
], $config);
$this->apiKey = $this->config['api_key'] ?? null;
if ($this->apiKey) {
Stripe::setApiKey($this->apiKey);
}
}
public function getName(): string
{
return 'stripe';
}
public function isActive(): bool
{
return ! empty($this->apiKey) && $this->apiKey !== 'sk_test_placeholder';
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
// Create or retrieve Stripe customer
$customer = $this->getOrCreateCustomer($user);
// Create or retrieve Stripe product and price
$priceId = $this->getOrCreatePrice($plan);
// Create subscription
$subscriptionBuilder = $user->newSubscription('default', $priceId);
if (! empty($options['trial_days'])) {
$subscriptionBuilder->trialDays($options['trial_days']);
}
if (! empty($options['coupon'])) {
$subscriptionBuilder->withCoupon($options['coupon']);
}
if (! empty($options['payment_method'])) {
$subscriptionBuilder->create($options['payment_method']);
} else {
// Create checkout session for payment method collection
$sessionId = $this->createCheckoutSession($user, $plan, $options);
return [
'requires_action' => true,
'checkout_session_id' => $sessionId,
'type' => 'checkout_session',
];
}
$stripeSubscription = $subscriptionBuilder->create();
return [
'provider_subscription_id' => $stripeSubscription->stripe_id,
'status' => $stripeSubscription->stripe_status,
'current_period_start' => $stripeSubscription->created_at,
'current_period_end' => $stripeSubscription->ends_at,
'trial_ends_at' => $stripeSubscription->trial_ends_at,
'customer_id' => $stripeSubscription->stripe_id,
];
} catch (Exception $e) {
Log::error('Stripe subscription creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
// Cancel immediately or at period end
$cashierSubscription->cancel();
return true;
} catch (Exception $e) {
Log::error('Stripe subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
$newPriceId = $this->getOrCreatePrice($newPlan);
// Swap to new plan
$cashierSubscription->swap($newPriceId);
return [
'provider_subscription_id' => $cashierSubscription->stripe_id,
'status' => $cashierSubscription->stripe_status,
'new_price_id' => $newPriceId,
];
} catch (Exception $e) {
Log::error('Stripe subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
try {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
// Stripe doesn't have a native pause feature, so we cancel at period end
$cashierSubscription->cancel();
return true;
} catch (Exception $e) {
Log::error('Stripe subscription pause failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function resumeSubscription(Subscription $subscription): bool
{
try {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
// Resume cancelled subscription
$cashierSubscription->resume();
return true;
} catch (Exception $e) {
Log::error('Stripe subscription resume failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($providerSubscriptionId);
return [
'id' => $stripeSubscription->id,
'status' => $stripeSubscription->status,
'current_period_start' => $stripeSubscription->current_period_start,
'current_period_end' => $stripeSubscription->current_period_end,
'trial_start' => $stripeSubscription->trial_start,
'trial_end' => $stripeSubscription->trial_end,
'customer' => $stripeSubscription->customer,
'items' => $stripeSubscription->items->data,
'created' => $stripeSubscription->created,
'ended_at' => $stripeSubscription->ended_at,
'canceled_at' => $stripeSubscription->canceled_at,
];
} catch (Exception $e) {
Log::error('Stripe subscription details retrieval failed', [
'subscription_id' => $providerSubscriptionId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$priceId = $this->getOrCreatePrice($plan);
$sessionData = [
'customer' => $customer->id,
'payment_method_types' => ['card'],
'line_items' => [[
'price' => $priceId,
'quantity' => 1,
]],
'mode' => $plan->monthly_billing ? 'subscription' : 'payment',
'success_url' => $options['success_url'] ?? $this->config['success_url'],
'cancel_url' => $options['cancel_url'] ?? $this->config['cancel_url'],
'metadata' => [
'user_id' => $user->id,
'plan_id' => $plan->id,
],
];
if ($plan->monthly_billing && ! empty($options['trial_days'])) {
$sessionData['subscription_data'] = [
'trial_period_days' => $options['trial_days'],
];
}
if (! empty($options['coupon'])) {
$sessionData['discounts'] = [['coupon' => $options['coupon']]];
}
$session = Session::create($sessionData);
return [
'checkout_session_id' => $session->id,
'checkout_url' => $session->url,
];
} catch (Exception $e) {
Log::error('Stripe checkout session creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCustomerPortalSession(User $user): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$session = \Stripe\BillingPortal\Session::create([
'customer' => $customer->id,
'return_url' => route('dashboard'),
]);
return [
'portal_session_id' => $session->id,
'portal_url' => $session->url,
];
} catch (Exception $e) {
Log::error('Stripe customer portal session creation failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$event = \Stripe\Event::constructFrom(
json_decode($payload, true)
);
$result = [
'event_type' => $event->type,
'processed' => false,
'data' => [],
];
switch ($event->type) {
case 'invoice.payment_succeeded':
$result = $this->handleInvoicePaymentSucceeded($event);
break;
case 'invoice.payment_failed':
$result = $this->handleInvoicePaymentFailed($event);
break;
case 'customer.subscription.created':
$result = $this->handleSubscriptionCreated($event);
break;
case 'customer.subscription.updated':
$result = $this->handleSubscriptionUpdated($event);
break;
case 'customer.subscription.deleted':
$result = $this->handleSubscriptionDeleted($event);
break;
default:
Log::info('Unhandled Stripe webhook event', ['event_type' => $event->type]);
}
return $result;
} catch (Exception $e) {
Log::error('Stripe webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
throw $e;
}
}
public function validateWebhook(Request $request): bool
{
try {
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
if (! $sigHeader) {
return false;
}
\Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
$this->config['webhook_secret']
);
return true;
} catch (Exception $e) {
Log::warning('Stripe webhook validation failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
public function getConfiguration(): array
{
return $this->config;
}
public function syncSubscriptionStatus(Subscription $subscription): array
{
return $this->getSubscriptionDetails($subscription->provider_subscription_id);
}
public function getPaymentMethodDetails(string $paymentMethodId): array
{
try {
$paymentMethod = \Stripe\PaymentMethod::retrieve($paymentMethodId);
return [
'id' => $paymentMethod->id,
'type' => $paymentMethod->type,
'card' => [
'brand' => $paymentMethod->card->brand,
'last4' => $paymentMethod->card->last4,
'exp_month' => $paymentMethod->card->exp_month,
'exp_year' => $paymentMethod->card->exp_year,
],
'created' => $paymentMethod->created,
];
} catch (Exception $e) {
Log::error('Stripe payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $paymentIntentId, float $amount, string $reason = ''): array
{
try {
$refund = \Stripe\Refund::create([
'payment_intent' => $paymentIntentId,
'amount' => (int) ($amount * 100), // Convert to cents
'reason' => $reason ?: 'requested_by_customer',
]);
return [
'refund_id' => $refund->id,
'amount' => $refund->amount / 100,
'status' => $refund->status,
'created' => $refund->created,
];
} catch (Exception $e) {
Log::error('Stripe refund processing failed', [
'payment_intent_id' => $paymentIntentId,
'amount' => $amount,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$charges = \Stripe\Charge::all([
'customer' => $customer->id,
'limit' => $filters['limit'] ?? 100,
'starting_after' => $filters['starting_after'] ?? null,
]);
$transactions = [];
foreach ($charges->data as $charge) {
$transactions[] = [
'id' => $charge->id,
'amount' => $charge->amount / 100,
'currency' => $charge->currency,
'status' => $charge->status,
'created' => $charge->created,
'description' => $charge->description,
'payment_method' => $charge->payment_method,
];
}
return $transactions;
} catch (Exception $e) {
Log::error('Stripe transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Stripe fees: 2.9% + $0.30 (US), varies by country
$fixedFee = 0.30; // $0.30
$percentageFee = 2.9; // 2.9%
$percentageAmount = ($amount * $percentageFee) / 100;
$totalFee = $fixedFee + $percentageAmount;
return [
'fixed_fee' => $fixedFee,
'percentage_fee' => $percentageAmount,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
public function getSupportedCurrencies(): array
{
return [
'USD', 'EUR', 'GBP', 'CAD', 'AUD', 'CHF', 'SEK', 'NOK', 'DKK',
'PLN', 'CZK', 'HUF', 'RON', 'BGN', 'HRK', 'RUB', 'TRY', 'MXN',
'BRL', 'ARS', 'CLP', 'COP', 'PEN', 'UYU', 'JPY', 'SGD', 'HKD',
'INR', 'MYR', 'THB', 'PHP', 'TWD', 'KRW', 'CNY',
];
}
public function supportsRecurring(): bool
{
return true;
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
protected function getOrCreateCustomer(User $user): Customer
{
if ($user->stripe_id) {
return Customer::retrieve($user->stripe_id);
}
$customer = Customer::create([
'email' => $user->email,
'name' => $user->name,
'metadata' => [
'user_id' => $user->id,
],
]);
$user->update(['stripe_id' => $customer->id]);
return $customer;
}
protected function getOrCreatePrice(Plan $plan): string
{
// Check if plan already has a Stripe price ID
if (! empty($plan->details['stripe_price_id'])) {
return $plan->details['stripe_price_id'];
}
// Create product if it doesn't exist
$productId = $this->getOrCreateProduct($plan);
// Create price
$priceData = [
'product' => $productId,
'unit_amount' => intval($plan->price * 100), // Convert to cents
'currency' => strtolower($this->config['currency']),
];
if ($plan->monthly_billing) {
$priceData['recurring'] = [
'interval' => 'month',
'interval_count' => 1,
];
}
$price = Price::create($priceData);
// Update plan with new price ID
$planDetails = $plan->details ?? [];
$planDetails['stripe_price_id'] = $price->id;
$plan->update(['details' => $planDetails]);
return $price->id;
}
protected function getOrCreateProduct(Plan $plan): string
{
// Check if plan already has a Stripe product ID
if (! empty($plan->details['stripe_product_id'])) {
return $plan->details['stripe_product_id'];
}
// Create product
$product = Product::create([
'name' => $plan->name,
'description' => $plan->description,
'metadata' => [
'plan_id' => $plan->id,
],
]);
// Update plan with new product ID
$planDetails = $plan->details ?? [];
$planDetails['stripe_product_id'] = $product->id;
$plan->update(['details' => $planDetails]);
return $product->id;
}
// Webhook handlers
protected function handleInvoicePaymentSucceeded($event): array
{
$invoice = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'invoice_id' => $invoice->id,
'subscription_id' => $invoice->subscription,
'amount_paid' => $invoice->amount_paid / 100,
'status' => 'paid',
],
];
}
protected function handleInvoicePaymentFailed($event): array
{
$invoice = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'invoice_id' => $invoice->id,
'subscription_id' => $invoice->subscription,
'attempt_count' => $invoice->attempt_count,
'status' => 'payment_failed',
],
];
}
protected function handleSubscriptionCreated($event): array
{
$subscription = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'subscription_id' => $subscription->id,
'customer_id' => $subscription->customer,
'status' => $subscription->status,
],
];
}
protected function handleSubscriptionUpdated($event): array
{
$subscription = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'subscription_id' => $subscription->id,
'status' => $subscription->status,
'current_period_start' => $subscription->current_period_start,
'current_period_end' => $subscription->current_period_end,
],
];
}
protected function handleSubscriptionDeleted($event): array
{
$subscription = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'subscription_id' => $subscription->id,
'status' => 'canceled',
'ended_at' => $subscription->ended_at,
],
];
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
return $subscription->provider_data['metadata'] ?? [];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->metadata = $metadata;
$stripeSubscription->save();
return true;
} catch (Exception $e) {
Log::error('Failed to update subscription metadata', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->trial_end = now()->addDays($trialDays)->timestamp;
$stripeSubscription->save();
return true;
} catch (Exception $e) {
Log::error('Failed to start trial', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->discount = null; // Remove existing discount
$stripeSubscription->save();
// Apply new coupon
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$coupon = \Stripe\Coupon::retrieve($couponCode);
return [
'coupon_id' => $coupon->id,
'amount_off' => $coupon->amount_off ?? null,
'percent_off' => $coupon->percent_off ?? null,
];
} catch (Exception $e) {
Log::error('Failed to apply coupon', [
'subscription_id' => $subscription->id,
'coupon_code' => $couponCode,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function removeCoupon(Subscription $subscription): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->deleteDiscount();
return true;
} catch (Exception $e) {
Log::error('Failed to remove coupon', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function getUpcomingInvoice(Subscription $subscription): array
{
try {
$invoice = \Stripe\Invoice::upcoming(['subscription' => $subscription->provider_subscription_id]);
return [
'amount_due' => $invoice->amount_due / 100,
'currency' => $invoice->currency,
'period_start' => $invoice->period_start,
'period_end' => $invoice->period_end,
'lines' => $invoice->lines->data,
];
} catch (Exception $e) {
Log::error('Failed to get upcoming invoice', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function retryFailedPayment(Subscription $subscription): array
{
try {
$invoice = \Stripe\Invoice::retrieve(['subscription' => $subscription->provider_subscription_id]);
$invoice->pay();
return [
'invoice_id' => $invoice->id,
'status' => $invoice->status,
'amount_paid' => $invoice->amount_paid / 100,
];
} catch (Exception $e) {
Log::error('Failed to retry payment', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
return in_array($stripeSubscription->status, ['active', 'trialing']);
} catch (Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'pro_rata',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => true,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'stripe',
'provider_subscription_id' => $subscription->provider_subscription_id,
'stripe_id' => $subscription->stripe_id,
'status' => $subscription->stripe_status,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
// This would be used for migrating subscriptions to Stripe
// Implementation depends on specific requirements
throw new Exception('Import to Stripe not implemented');
}
}

View File

@@ -0,0 +1,325 @@
<?php
namespace App\Services;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\SubscriptionChange;
use App\Services\Payments\PaymentOrchestrator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SubscriptionMigrationService
{
public function __construct(
private PaymentOrchestrator $orchestrator
) {}
/**
* Migrate subscription to a new plan
*/
public function migrateToPlan(Subscription $subscription, Plan $newPlan, string $reason = ''): bool
{
try {
DB::beginTransaction();
$oldPlan = $subscription->plan;
$oldValues = [
'plan_id' => $subscription->plan_id,
'plan_name' => $oldPlan?->name,
'price' => $oldPlan?->price,
];
// Update subscription with new plan
$subscription->update([
'plan_id' => $newPlan->id,
'updated_at' => now(),
]);
// Record the change
SubscriptionChange::createRecord(
$subscription,
'plan_change',
"Migrated from {$oldPlan?->name} to {$newPlan->name}",
$oldValues,
[
'plan_id' => $newPlan->id,
'plan_name' => $newPlan->name,
'price' => $newPlan->price,
],
$reason
);
DB::commit();
Log::info('Subscription plan migration completed', [
'subscription_id' => $subscription->id,
'old_plan_id' => $oldPlan?->id,
'new_plan_id' => $newPlan->id,
'reason' => $reason,
]);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error('Subscription plan migration failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Migrate subscription to a new provider
*/
public function migrateToProvider(Subscription $subscription, string $newProvider, array $providerData = []): bool
{
try {
DB::beginTransaction();
$oldProvider = $subscription->provider;
$oldValues = [
'provider' => $oldProvider,
'provider_subscription_id' => $subscription->provider_subscription_id,
];
// Cancel subscription with old provider if needed
if ($oldProvider && $subscription->isActive()) {
$this->orchestrator->cancelSubscription($subscription, 'Provider migration');
}
// Update subscription with new provider
$subscription->update([
'provider' => $newProvider,
'provider_subscription_id' => $providerData['subscription_id'] ?? null,
'provider_data' => array_merge($subscription->provider_data ?? [], $providerData),
'last_provider_sync' => now(),
]);
// Record the change
SubscriptionChange::createRecord(
$subscription,
'provider_change',
"Migrated from {$oldProvider} to {$newProvider}",
$oldValues,
[
'provider' => $newProvider,
'provider_subscription_id' => $providerData['subscription_id'] ?? null,
],
'Provider migration for better service'
);
DB::commit();
Log::info('Subscription provider migration completed', [
'subscription_id' => $subscription->id,
'old_provider' => $oldProvider,
'new_provider' => $newProvider,
]);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error('Subscription provider migration failed', [
'subscription_id' => $subscription->id,
'new_provider' => $newProvider,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Pause subscription
*/
public function pauseSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
DB::beginTransaction();
$oldValues = [
'status' => $subscription->status,
'paused_at' => null,
];
// Update subscription status
$subscription->update([
'status' => 'paused',
'paused_at' => now(),
]);
// Record the change
SubscriptionChange::createRecord(
$subscription,
'pause',
'Subscription paused',
$oldValues,
[
'status' => 'paused',
'paused_at' => now()->format('Y-m-d H:i:s'),
],
$reason
);
DB::commit();
Log::info('Subscription paused', [
'subscription_id' => $subscription->id,
'reason' => $reason,
]);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to pause subscription', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Resume subscription
*/
public function resumeSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
DB::beginTransaction();
$oldValues = [
'status' => $subscription->status,
'paused_at' => $subscription->paused_at,
'resumed_at' => null,
];
// Update subscription status
$subscription->update([
'status' => 'active',
'resumed_at' => now(),
]);
// Record the change
SubscriptionChange::createRecord(
$subscription,
'resume',
'Subscription resumed',
$oldValues,
[
'status' => 'active',
'resumed_at' => now()->format('Y-m-d H:i:s'),
],
$reason
);
DB::commit();
Log::info('Subscription resumed', [
'subscription_id' => $subscription->id,
'reason' => $reason,
]);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to resume subscription', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Bulk migrate subscriptions
*/
public function bulkMigrate(array $subscriptionIds, callable $migrationCallback): array
{
$results = [
'success' => 0,
'failed' => 0,
'errors' => [],
];
foreach ($subscriptionIds as $subscriptionId) {
try {
$subscription = Subscription::findOrFail($subscriptionId);
if ($migrationCallback($subscription)) {
$results['success']++;
} else {
$results['failed']++;
$results['errors'][] = "Migration failed for subscription {$subscriptionId}";
}
} catch (\Exception $e) {
$results['failed']++;
$results['errors'][] = "Error processing subscription {$subscriptionId}: {$e->getMessage()}";
}
}
return $results;
}
/**
* Get migration history for a subscription
*/
public function getMigrationHistory(Subscription $subscription): \Illuminate\Database\Eloquent\Collection
{
return $subscription->subscriptionChanges()
->whereIn('change_type', ['plan_change', 'provider_change', 'migration'])
->orderBy('effective_at', 'desc')
->get();
}
/**
* Get pending migrations
*/
public function getPendingMigrations(): \Illuminate\Database\Eloquent\Collection
{
return SubscriptionChange::query()
->whereIn('change_type', ['plan_change', 'provider_change', 'migration'])
->pending()
->with(['subscription', 'user'])
->orderBy('effective_at', 'asc')
->get();
}
/**
* Process pending migrations
*/
public function processPendingMigrations(): int
{
$pending = $this->getPendingMigrations();
$processedCount = 0;
foreach ($pending as $change) {
try {
// Here you would implement the actual migration logic
// based on the change type and data
$change->markAsProcessed();
$processedCount++;
} catch (\Exception $e) {
Log::error('Failed to process pending migration', [
'change_id' => $change->id,
'error' => $e->getMessage(),
]);
}
}
return $processedCount;
}
}