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