Files
zemailnator/app/Http/Controllers/PaymentController.php
idevakk 75086ad83b feat(payment): integrate Polar.sh payment provider with checkout flow
- Build PolarProvider from scratch with proper HTTP API integration
  - Add encrypted configuration loading from payment_providers table via model
  - Implement sandbox/live environment switching with proper credential handling
  - Fix product creation API structure for Polar.sh requirements
  - Add comprehensive error handling and logging throughout checkout flow
  - Fix PaymentController checkout URL handling to support Polar's checkout_url response
  - Add debug logging for troubleshooting checkout session creation
  - Support both regular and trial checkout flows for Polar payments
2025-12-04 10:29:25 -08:00

738 lines
25 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\Support\Facades\Log;
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);
}
Log::info('PaymentController: Creating checkout session', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'requested_provider' => $provider,
'is_trial' => $isTrial,
'options_count' => count($options),
]);
$result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $options);
Log::info('PaymentController: Returning successful checkout response', [
'result_keys' => array_keys($result),
'has_checkout_url' => isset($result['checkout_url']),
'checkout_url' => $result['checkout_url'] ?? 'missing',
'provider_subscription_id' => $result['provider_subscription_id'] ?? 'missing',
]);
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 enhanced checkout with provider selection
*/
public function enhancedCheckout(Request $request, int $plan, string $provider)
{
try {
$user = $request->user();
$planModel = Plan::with(['planProviders', 'trialConfiguration'])->findOrFail($plan);
// Validate provider support
if (! $planModel->supportsProvider($provider)) {
session()->flash('error', "Provider '{$provider}' is not supported for this plan.");
return redirect()->route('dashboard');
}
// Create checkout session via orchestrator
$result = $this->orchestrator->createCheckoutSession($user, $planModel, $provider, [
'success_url' => route('checkout.success'),
'cancel_url' => route('checkout.cancel'),
'is_trial' => false,
]);
Log::info('PaymentController: enhancedCheckout result', [
'result_keys' => array_keys($result),
'has_redirect_url' => isset($result['redirect_url']),
'has_session_url' => isset($result['session_url']),
'has_checkout_url' => isset($result['checkout_url']),
'checkout_url' => $result['checkout_url'] ?? 'missing',
]);
// Redirect to provider's checkout page
if (isset($result['redirect_url'])) {
return redirect($result['redirect_url']);
}
// Fallback to session-based checkout
if (isset($result['session_url'])) {
return redirect($result['session_url']);
}
// Polar checkout URL handling
if (isset($result['checkout_url'])) {
return redirect($result['checkout_url']);
}
session()->flash('error', 'Unable to create checkout session. Please try again.');
return redirect()->route('dashboard');
} catch (\Exception $e) {
session()->flash('error', 'Checkout error: '.$e->getMessage());
return redirect()->route('dashboard');
}
}
/**
* Handle trial-specific checkout flow
*/
public function trialCheckout(Request $request, int $plan, string $provider)
{
try {
$user = $request->user();
$planModel = Plan::with(['trialConfiguration', 'planProviders'])->findOrFail($plan);
// Validate trial availability
if (! $planModel->hasTrial()) {
session()->flash('error', 'This plan does not offer trials.');
return redirect()->route('dashboard');
}
// Validate provider support
if (! $planModel->supportsProvider($provider)) {
session()->flash('error', "Provider '{$provider}' is not supported for this plan.");
return redirect()->route('dashboard');
}
$trialConfig = $planModel->getTrialConfig();
// Create trial checkout session
$result = $this->orchestrator->createCheckoutSession($user, $planModel, $provider, [
'success_url' => route('checkout.success'),
'cancel_url' => route('checkout.cancel'),
'is_trial' => true,
'trial_duration_days' => $trialConfig?->trial_duration_days ?? 14,
'trial_requires_payment_method' => $trialConfig?->trial_requires_payment_method ?? true,
]);
Log::info('PaymentController: trialCheckout result', [
'result_keys' => array_keys($result),
'has_redirect_url' => isset($result['redirect_url']),
'has_session_url' => isset($result['session_url']),
'has_checkout_url' => isset($result['checkout_url']),
'checkout_url' => $result['checkout_url'] ?? 'missing',
]);
// Redirect to provider's checkout page
if (isset($result['redirect_url'])) {
return redirect($result['redirect_url']);
}
// Fallback to session-based checkout
if (isset($result['session_url'])) {
return redirect($result['session_url']);
}
// Polar checkout URL handling
if (isset($result['checkout_url'])) {
return redirect($result['checkout_url']);
}
session()->flash('error', 'Unable to create trial checkout session. Please try again.');
return redirect()->route('dashboard');
} catch (\Exception $e) {
session()->flash('error', 'Trial checkout error: '.$e->getMessage());
return redirect()->route('dashboard');
}
}
/**
* 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';
}
}