- Add comprehensive feature limits enforcement middleware - Implement subscription dashboard with usage analytics - Create reusable plan card component with feature badges - Add trial configuration support with limit overrides - Fix payment controller null safety issues - Improve pricing page UI with proper feature display
597 lines
20 KiB
PHP
597 lines
20 KiB
PHP
<?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 readonly 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',
|
|
'is_trial' => 'nullable|boolean',
|
|
]);
|
|
|
|
$user = $request->user();
|
|
$plan = Plan::with(['planProviders', 'trialConfiguration'])->findOrFail($validated['plan_id']);
|
|
$provider = $validated['provider'] ?? null;
|
|
$options = $validated['options'] ?? [];
|
|
$isTrial = $validated['is_trial'] ?? false;
|
|
|
|
// Validate provider support
|
|
if ($provider && ! $plan?->supportsProvider($provider)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => "Provider '{$provider}' is not supported for this plan.",
|
|
], 400);
|
|
}
|
|
|
|
// Validate trial requirements
|
|
if ($isTrial) {
|
|
if (! $plan?->hasTrial()) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'This plan does not offer trials.',
|
|
], 400);
|
|
}
|
|
|
|
$trialConfig = $plan?->getTrialConfig();
|
|
if ($trialConfig && $trialConfig->trial_requires_payment_method && ! $provider) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'Payment method is required for trial. Please specify a provider.',
|
|
], 400);
|
|
}
|
|
}
|
|
|
|
// Enhance options with plan-specific data
|
|
$options = array_merge($options, [
|
|
'is_trial' => $isTrial,
|
|
'plan_features' => $plan?->getFeaturesWithLimits() ?? [],
|
|
'billing_cycle' => $plan?->getBillingCycleDisplay() ?? 'Unknown',
|
|
'plan_tier' => $plan?->planTier?->name,
|
|
]);
|
|
|
|
if (! $plan) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'Plan not found.',
|
|
], 404);
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
if (! $plan) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'Plan not found.',
|
|
], 404);
|
|
}
|
|
|
|
$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']);
|
|
|
|
if (! $plan) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'Plan not found.',
|
|
], 404);
|
|
}
|
|
|
|
$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 ?? 0),
|
|
'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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start a trial subscription
|
|
*/
|
|
public function startTrial(Request $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validate([
|
|
'plan_id' => 'required|exists:plans,id',
|
|
'provider' => 'required|string|in:stripe,lemon_squeezy,polar',
|
|
'options' => 'nullable|array',
|
|
]);
|
|
|
|
$user = $request->user();
|
|
$plan = Plan::with(['trialConfiguration', 'planProviders'])->findOrFail($validated['plan_id']);
|
|
$provider = $validated['provider'];
|
|
$options = $validated['options'] ?? [];
|
|
|
|
// Validate trial availability
|
|
if (! $plan?->hasTrial()) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'This plan does not offer trials.',
|
|
], 400);
|
|
}
|
|
|
|
// Validate provider support
|
|
if (! $plan?->supportsProvider($provider)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => "Provider '{$provider}' is not supported for this plan.",
|
|
], 400);
|
|
}
|
|
|
|
$trialConfig = $plan?->getTrialConfig();
|
|
|
|
// Check if user already has an active trial for this plan
|
|
$existingTrial = $user->subscriptions()
|
|
->where('plan_id', $plan?->id)
|
|
->where('status', 'trialing')
|
|
->first();
|
|
|
|
if ($existingTrial) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'You already have an active trial for this plan.',
|
|
], 400);
|
|
}
|
|
|
|
if (! $plan) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'Plan not found.',
|
|
], 404);
|
|
}
|
|
|
|
// Create trial subscription using checkout session with trial options
|
|
$trialOptions = array_merge($options, [
|
|
'is_trial' => true,
|
|
'trial_duration_days' => $trialConfig?->trial_duration_days ?? 14,
|
|
'trial_requires_payment_method' => $trialConfig?->trial_requires_payment_method ?? true,
|
|
]);
|
|
|
|
$result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $trialOptions);
|
|
|
|
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 plan comparison data
|
|
*/
|
|
public function getPlanComparison(Request $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validate([
|
|
'plan_ids' => 'required|array|min:1|max:5',
|
|
'plan_ids.*' => 'exists:plans,id',
|
|
]);
|
|
|
|
$plans = Plan::with([
|
|
'planFeatureLimits.planFeature',
|
|
'planProviders',
|
|
'trialConfiguration',
|
|
'planTier',
|
|
])
|
|
->whereIn('id', $validated['plan_ids'])
|
|
->active()
|
|
->ordered()
|
|
->get();
|
|
|
|
$comparison = $plans->map(function ($plan) {
|
|
$trialConfig = $plan?->getTrialConfig();
|
|
|
|
return [
|
|
'id' => $plan?->id,
|
|
'name' => $plan?->name,
|
|
'description' => $plan?->description,
|
|
'price' => $plan?->price,
|
|
'billing_cycle' => $plan?->getBillingCycleDisplay() ?? 'Unknown',
|
|
'tier' => $plan?->planTier?->name,
|
|
'providers' => $plan?->getAllowedProviders() ?? [],
|
|
'features' => $plan?->getFeaturesWithLimits() ?? [],
|
|
'trial' => $plan?->hasTrial() ? [
|
|
'available' => true,
|
|
'duration_days' => $trialConfig?->trial_duration_days ?? 0,
|
|
'requires_payment_method' => $trialConfig?->trial_requires_payment_method ?? false,
|
|
] : ['available' => false],
|
|
];
|
|
});
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => $comparison,
|
|
]);
|
|
|
|
} 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 upgrade paths for user's current subscription
|
|
*/
|
|
public function getUpgradePaths(Request $request): JsonResponse
|
|
{
|
|
try {
|
|
$user = $request->user();
|
|
$currentSubscription = $user->subscription('default');
|
|
|
|
if (! $currentSubscription) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => 'No active subscription found.',
|
|
], 404);
|
|
}
|
|
|
|
$currentPlan = $currentSubscription->plan;
|
|
$upgradePaths = Plan::with(['planTier', 'planFeatureLimits.planFeature'])
|
|
->where('id', '!=', $currentPlan->id)
|
|
->where(function ($query) use ($currentPlan) {
|
|
$query->where('price', '>', $currentPlan->price)
|
|
->orWhereHas('planTier', function ($q) use ($currentPlan) {
|
|
$q->where('sort_order', '>', $currentPlan->planTier?->sort_order ?? 0);
|
|
});
|
|
})
|
|
->active()
|
|
->ordered()
|
|
->get();
|
|
|
|
$paths = $upgradePaths->map(function ($plan) use ($currentPlan) {
|
|
return [
|
|
'plan' => [
|
|
'id' => $plan->id,
|
|
'name' => $plan->name,
|
|
'price' => $plan->price,
|
|
'billing_cycle' => $plan->getBillingCycleDisplay(),
|
|
'tier' => $plan->planTier?->name,
|
|
],
|
|
'upgrade_benefits' => $this->getUpgradeBenefits($currentPlan, $plan),
|
|
'providers' => $plan->getAllowedProviders(),
|
|
'can_migrate' => true,
|
|
];
|
|
});
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => [
|
|
'current_plan' => [
|
|
'id' => $currentPlan->id,
|
|
'name' => $currentPlan->name,
|
|
'price' => $currentPlan->price,
|
|
],
|
|
'upgrade_paths' => $paths,
|
|
],
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get upgrade benefits between two plans
|
|
*/
|
|
private function getUpgradeBenefits(Plan $currentPlan, Plan $newPlan): array
|
|
{
|
|
$currentFeatures = $currentPlan->getFeaturesWithLimits();
|
|
$newFeatures = $newPlan->getFeaturesWithLimits();
|
|
|
|
$benefits = [];
|
|
|
|
foreach ($newFeatures as $featureData) {
|
|
$feature = $featureData['feature'];
|
|
$newLimit = $featureData['limit'];
|
|
$currentLimit = collect($currentFeatures)
|
|
->firstWhere('feature.id', $feature->id)['limit'] ?? null;
|
|
|
|
if (! $currentLimit || $this->isUpgradeBenefit($currentLimit, $newLimit)) {
|
|
$benefits[] = [
|
|
'feature' => $feature->display_name,
|
|
'from' => $this->formatLimitDisplay($currentLimit),
|
|
'to' => $this->formatLimitDisplay($newLimit),
|
|
'improvement' => $this->getImprovementType($currentLimit, $newLimit),
|
|
];
|
|
}
|
|
}
|
|
|
|
return $benefits;
|
|
}
|
|
|
|
/**
|
|
* Check if a limit change is an upgrade benefit
|
|
*/
|
|
private function isUpgradeBenefit($currentLimit, $newLimit): bool
|
|
{
|
|
if (! $currentLimit) {
|
|
return true;
|
|
}
|
|
if (! $newLimit) {
|
|
return false;
|
|
}
|
|
|
|
// Boolean upgrades
|
|
if ($newLimit->limit_type === 'boolean') {
|
|
return ! $currentLimit->limit_value && $newLimit->limit_value;
|
|
}
|
|
|
|
// Numeric upgrades
|
|
if ($newLimit->limit_type === 'numeric') {
|
|
return ($newLimit->limit_value ?? 0) > ($currentLimit->limit_value ?? 0);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Format limit value for display
|
|
*/
|
|
private function formatLimitDisplay($limit): string
|
|
{
|
|
if (! $limit) {
|
|
return 'Not Available';
|
|
}
|
|
|
|
if ($limit->limit_type === 'boolean') {
|
|
return $limit->limit_value ? 'Enabled' : 'Disabled';
|
|
}
|
|
|
|
if ($limit->limit_type === 'numeric') {
|
|
return $limit->limit_value ? (string) $limit->limit_value : 'Unlimited';
|
|
}
|
|
|
|
return 'Limited';
|
|
}
|
|
|
|
/**
|
|
* Get improvement type for upgrade benefit
|
|
*/
|
|
private function getImprovementType($currentLimit, $newLimit): string
|
|
{
|
|
if (! $currentLimit) {
|
|
return 'New Feature';
|
|
}
|
|
if ($newLimit->limit_type === 'boolean') {
|
|
return 'Enabled';
|
|
}
|
|
if ($newLimit->limit_type === 'numeric') {
|
|
return 'Increased Limit';
|
|
}
|
|
|
|
return 'Improved';
|
|
}
|
|
}
|