--- File: D:\projects\digitalvocano\Modules\StripeGateway\app\Http\Controllers\Admin\StripeConfigController.php --- route('admin.dashboard')->with('error', 'Settings helper function not found.'); } $settings = []; foreach ($this->settingKeys as $key) { $settings[$key] = setting($key); } return view('stripegateway::admin.config', compact('settings')); } public function update(Request $request) { if (!function_exists('setting')) { return redirect()->route('admin.dashboard')->with('error', 'Settings helper function not found.'); } $validated = $request->validate([ 'stripe_enabled' => 'nullable|boolean', 'stripe_publishable_key' => 'nullable|string|max:255', 'stripe_secret_key' => 'nullable|string|max:255', 'stripe_webhook_secret' => 'nullable|string|max:255', ]); try { foreach ($this->settingKeys as $key) { $value = $request->has($key) ? ($key === 'stripe_enabled' ? (bool)$request->input($key) : $request->input($key)) : ($key === 'stripe_enabled' ? '0' : null); // For boolean 'stripe_enabled', ensure '0' or '1' is stored if it's a checkbox if ($key === 'stripe_enabled') { $valueToStore = $request->has('stripe_enabled') ? '1' : '0'; } else { $valueToStore = $request->input($key); } Setting::updateOrCreate( ['key' => $key], ['value' => $valueToStore] ); } // Clear cache for settings to take effect immediately Artisan::call('cache:clear'); Artisan::call('config:clear'); return redirect()->back()->with('success', 'Stripe settings updated successfully.'); } catch (\Exception $e) { Log::error('Error updating Stripe settings: ' . $e->getMessage()); return redirect()->back()->with('error', 'Failed to update Stripe settings. Please check the logs.'); } } } --- File: D:\projects\digitalvocano\Modules\StripeGateway\app\Http\Controllers\StripeGatewayController.php --- stripeService = $stripeService; } public function checkout(Request $request, SubscriptionPlan $plan) { if (setting('stripe_enabled', '0') != '1') { return redirect()->route('subscription.plans')->with('error', 'Stripe payments are currently disabled.'); } /** @var User $user */ $user = Auth::user(); // Check if user already has an active subscription to this plan or any plan // (You might want more sophisticated logic here, e.g., allow upgrades/downgrades) $activeSubscription = $user->subscriptions() ->whereIn('status', ['active', 'trialing']) ->first(); if ($activeSubscription) { // If they are trying to subscribe to the same plan they already have and it's active/trialing if ($activeSubscription->subscription_plan_id == $plan->id) { return redirect()->route('subscription.plans')->with('info', 'You are already subscribed to this plan.'); } // For now, prevent new subscriptions if one is active. // Later, you can implement upgrade/downgrade logic. return redirect()->route('subscription.plans')->with('error', 'You already have an active subscription. Please manage it from your profile.'); } try { $checkoutSession = $this->stripeService->createCheckoutSession($user, $plan); return redirect($checkoutSession->url); } catch (\Exception $e) { Log::error("Stripe Checkout Error for user {$user->id}, plan {$plan->id}: " . $e->getMessage()); return redirect()->route('subscription.plans')->with('error', 'Could not initiate Stripe checkout. ' . $e->getMessage()); } } public function handleSuccess(Request $request) { $sessionId = $request->query('session_id'); if (!$sessionId) { return redirect()->route('subscription.plans')->with('error', 'Invalid Stripe session.'); } try { if (!setting('stripe_secret_key')) { throw new \Exception('Stripe secret key is not configured.'); } \Stripe\Stripe::setApiKey(setting('stripe_secret_key')); $session = StripeCheckoutSession::retrieve($sessionId, ['expand' => ['subscription', 'line_items.data.price.product']]); $user = User::find($session->metadata->user_id); $plan = SubscriptionPlan::find($session->metadata->plan_id); if (!$user || !$plan) { Log::error("Stripe success: User or Plan not found from session metadata.", ['session_id' => $sessionId, 'metadata' => $session->metadata]); return redirect()->route('subscription.plans')->with('error', 'Subscription details could not be verified.'); } // Check if a subscription record already exists for this Stripe subscription ID // This can happen if the webhook processed it first $existingSubscription = Subscription::where('gateway_subscription_id', $session->subscription->id)->first(); if ($existingSubscription) { Log::info("Stripe success: Subscription already processed by webhook for Stripe sub ID: " . $session->subscription->id); // Potentially update status if it was pending, though webhook should handle this. if ($existingSubscription->status === 'pending') { $existingSubscription->status = $session->subscription->status; // e.g., 'active' or 'trialing' $existingSubscription->starts_at = $session->subscription->current_period_start ? \Carbon\Carbon::createFromTimestamp($session->subscription->current_period_start) : now(); $existingSubscription->ends_at = $session->subscription->current_period_end ? \Carbon\Carbon::createFromTimestamp($session->subscription->current_period_end) : null; if ($session->subscription->trial_end) { $existingSubscription->trial_ends_at = \Carbon\Carbon::createFromTimestamp($session->subscription->trial_end); } $existingSubscription->save(); } } else { // Create new local subscription record Subscription::create([ 'user_id' => $user->id, 'subscription_plan_id' => $plan->id, 'payment_gateway' => 'stripe', 'gateway_subscription_id' => $session->subscription->id, 'status' => $session->subscription->status, // e.g., 'active' or 'trialing' 'starts_at' => $session->subscription->current_period_start ? \Carbon\Carbon::createFromTimestamp($session->subscription->current_period_start) : now(), 'ends_at' => $session->subscription->current_period_end ? \Carbon\Carbon::createFromTimestamp($session->subscription->current_period_end) : null, 'trial_ends_at' => $session->subscription->trial_end ? \Carbon\Carbon::createFromTimestamp($session->subscription->trial_end) : null, ]); } // Update user's Stripe customer ID if it's not set (though StripeService should do this) if ($user && $session->customer && !$user->stripe_customer_id) { $user->stripe_customer_id = $session->customer; $user->save(); } return redirect()->route('dashboard')->with('success', 'Subscription successful! Welcome to ' . $plan->name . '.'); } catch (\Exception $e) { Log::error("Stripe Success Error: " . $e->getMessage(), ['session_id' => $sessionId]); return redirect()->route('subscription.plans')->with('error', 'There was an issue confirming your subscription: ' . $e->getMessage()); } } public function handleCancel(Request $request) { return redirect()->route('subscription.plans')->with('info', 'Your subscription process was cancelled.'); } public function redirectToCustomerPortal(Request $request) { /** @var User $user */ $user = Auth::user(); if (!$user->stripe_customer_id) { return redirect()->back()->with('error', 'Stripe customer profile not found.'); } try { $portalSession = $this->stripeService->createBillingPortalSession($user); return redirect($portalSession->url); } catch (\Exception $e) { Log::error("Stripe Billing Portal Error for user {$user->id}: " . $e->getMessage()); return redirect()->back()->with('error', 'Could not access the billing portal: ' . $e->getMessage()); } } public function cancelActiveSubscription(Request $request) { /** @var User $user */ $user = Auth::user(); $activeSubscription = $user->subscriptions() ->where('payment_gateway', 'stripe') ->whereIn('status', ['active', 'trialing']) ->orderBy('created_at', 'desc') ->first(); if (!$activeSubscription) { return redirect()->back()->with('info', 'No active Stripe subscription found to cancel.'); } try { $cancelledAtGateway = $this->stripeService->cancelSubscriptionAtGateway($activeSubscription); if ($cancelledAtGateway) { // Webhook should ideally handle the final status update, // but we can mark it as 'cancelled' locally for immediate feedback. $activeSubscription->status = 'cancelled'; // Or 'pending_cancellation' $activeSubscription->cancelled_at = now(); // ends_at will be updated by webhook when Stripe confirms cancellation at period end $activeSubscription->save(); return redirect()->back()->with('success', 'Your subscription has been scheduled for cancellation at the end of the current billing period.'); } else { return redirect()->back()->with('error', 'Failed to cancel subscription at Stripe. Please try again or contact support.'); } } catch (\Exception $e) { Log::error("Error cancelling active Stripe subscription for user {$user->id}, sub ID {$activeSubscription->gateway_subscription_id}: " . $e->getMessage()); return redirect()->back()->with('error', 'An error occurred while trying to cancel your subscription: ' . $e->getMessage()); } } } --- File: D:\projects\digitalvocano\Modules\StripeGateway\app\Http\Controllers\StripeWebhookController.php --- stripeService = $stripeService; } public function handleWebhook(Request $request) { $payload = $request->getContent(); $sigHeader = $request->header('Stripe-Signature'); $event = null; try { $event = $this->stripeService->verifyWebhookSignature($payload, $sigHeader); } catch (\UnexpectedValueException $e) { // Invalid payload Log::error('Stripe Webhook Error: Invalid payload.', ['exception' => $e->getMessage()]); return response()->json(['error' => 'Invalid payload'], 400); } catch (\Stripe\Exception\SignatureVerificationException $e) { // Invalid signature Log::error('Stripe Webhook Error: Invalid signature.', ['exception' => $e->getMessage()]); return response()->json(['error' => 'Invalid signature'], 400); } catch (\Exception $e) { Log::error('Stripe Webhook Error: Could not verify signature.', ['exception' => $e->getMessage()]); return response()->json(['error' => 'Signature verification error'], 400); } // Handle the event switch ($event->type) { case 'checkout.session.completed': $session = $event->data->object; // contains a \Stripe\Checkout\Session // This is often handled by the success redirect, but good to have a fallback // or if you need to do something specific when checkout is fully completed. // Ensure you get the subscription ID from the session: $session->subscription Log::info('Stripe Webhook: checkout.session.completed', ['session_id' => $session->id, 'subscription_id' => $session->subscription]); if ($session->subscription && $session->payment_status == 'paid') { $this->handleSubscriptionUpdate($session->subscription, $session->customer); } break; case 'customer.subscription.created': case 'customer.subscription.updated': case 'customer.subscription.resumed': // Stripe specific for resumed subscriptions $stripeSubscription = $event->data->object; // contains a \Stripe\Subscription Log::info('Stripe Webhook: ' . $event->type, ['subscription_id' => $stripeSubscription->id]); $this->handleSubscriptionUpdate($stripeSubscription->id, $stripeSubscription->customer); break; case 'customer.subscription.trial_will_end': $stripeSubscription = $event->data->object; Log::info('Stripe Webhook: customer.subscription.trial_will_end', ['subscription_id' => $stripeSubscription->id]); // Send a notification to the user break; case 'customer.subscription.deleted': // Occurs when a subscription is canceled immediately or at period end. $stripeSubscription = $event->data->object; // contains a \Stripe\Subscription Log::info('Stripe Webhook: customer.subscription.deleted', ['subscription_id' => $stripeSubscription->id]); $this->handleSubscriptionCancellation($stripeSubscription->id); break; case 'invoice.payment_succeeded': $invoice = $event->data->object; // contains an \Stripe\Invoice Log::info('Stripe Webhook: invoice.payment_succeeded', ['invoice_id' => $invoice->id, 'subscription_id' => $invoice->subscription]); if ($invoice->subscription) { // This event confirms a recurring payment was successful. // The customer.subscription.updated event usually handles the date changes. $this->handleSubscriptionUpdate($invoice->subscription, $invoice->customer); } break; case 'invoice.payment_failed': $invoice = $event->data->object; // contains an \Stripe\Invoice Log::error('Stripe Webhook: invoice.payment_failed', ['invoice_id' => $invoice->id, 'subscription_id' => $invoice->subscription]); if ($invoice->subscription) { $this->handleSubscriptionPaymentFailure($invoice->subscription); } break; // ... handle other event types default: Log::info('Stripe Webhook: Received unhandled event type ' . $event->type); } return response()->json(['status' => 'success']); } protected function handleSubscriptionUpdate($stripeSubscriptionId, $stripeCustomerId) { try { if (!setting('stripe_secret_key')) { throw new \Exception('Stripe secret key is not configured for webhook processing.'); } \Stripe\Stripe::setApiKey(setting('stripe_secret_key')); $stripeSubscription = StripeSubscriptionObject::retrieve($stripeSubscriptionId); // Use aliased StripeSubscriptionObject $localSubscription = Subscription::where('gateway_subscription_id', $stripeSubscription->id)->first(); $user = User::where('stripe_customer_id', $stripeCustomerId)->first(); if (!$user && $localSubscription) { // Fallback if customer ID wasn't set on user yet $user = $localSubscription->user; } if (!$user) { Log::error("Stripe Webhook: User not found for Stripe Customer ID: {$stripeCustomerId}"); return; } // Ensure user has stripe_customer_id if it's missing if ($user && $stripeCustomerId && !$user->stripe_customer_id) { $user->stripe_customer_id = $stripeCustomerId; $user->save(); } $plan = null; if (isset($stripeSubscription->items->data[0]->price->product)) { // This assumes you store Stripe Product ID or a mapping if your plan slugs don't match Stripe Product IDs // For simplicity, let's assume plan_id was stored in metadata or you can find it // A more robust way is to store Stripe Price ID on your SubscriptionPlan model. // For now, we'll rely on existing local subscription or metadata if available. } if (!$plan && $localSubscription) { $plan = $localSubscription->plan; } // If creating a new subscription from webhook (e.g. checkout.session.completed without success redirect hitting first) // You'd need to get plan_id from $stripeSubscription->metadata or items. // For now, this primarily updates existing subscriptions. if ($localSubscription) { $localSubscription->status = $stripeSubscription->status; $localSubscription->starts_at = \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_start); $localSubscription->ends_at = \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end); $localSubscription->trial_ends_at = $stripeSubscription->trial_end ? \Carbon\Carbon::createFromTimestamp($stripeSubscription->trial_end) : null; $localSubscription->cancelled_at = $stripeSubscription->cancel_at_period_end ? ($stripeSubscription->canceled_at ? \Carbon\Carbon::createFromTimestamp($stripeSubscription->canceled_at) : now()) : null; $localSubscription->save(); Log::info("Stripe Webhook: Updated local subscription ID {$localSubscription->id} to status {$stripeSubscription->status}"); } elseif ($user && $plan) { // Create if it doesn't exist (e.g. from checkout.session.completed) Subscription::create([ 'user_id' => $user->id, 'subscription_plan_id' => $plan->id, // This needs to be reliably determined 'payment_gateway' => 'stripe', 'gateway_subscription_id' => $stripeSubscription->id, 'status' => $stripeSubscription->status, 'starts_at' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_start), 'ends_at' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end), 'trial_ends_at' => $stripeSubscription->trial_end ? \Carbon\Carbon::createFromTimestamp($stripeSubscription->trial_end) : null, ]); Log::info("Stripe Webhook: Created new local subscription for user {$user->id}, Stripe sub ID {$stripeSubscription->id}"); } else { Log::warning("Stripe Webhook: Could not create or update local subscription for Stripe sub ID {$stripeSubscription->id}. User or Plan missing."); } } catch (\Exception $e) { Log::error("Stripe Webhook handleSubscriptionUpdate Error for sub ID {$stripeSubscriptionId}: " . $e->getMessage()); } } protected function handleSubscriptionCancellation($stripeSubscriptionId) { $localSubscription = Subscription::where('gateway_subscription_id', $stripeSubscriptionId)->first(); if ($localSubscription) { $localSubscription->status = 'cancelled'; // Or 'ended' depending on Stripe's final status $localSubscription->cancelled_at = now(); // ends_at might already be set if it was cancel_at_period_end if (!$localSubscription->ends_at || $localSubscription->ends_at->isFuture()) { // If Stripe cancels it immediately, set ends_at to now. // If it was cancel_at_period_end, ends_at should reflect that. // The customer.subscription.updated event with status 'canceled' often provides the final end date. } $localSubscription->save(); Log::info("Stripe Webhook: Cancelled local subscription ID {$localSubscription->id}"); } else { Log::warning("Stripe Webhook: Received cancellation for unknown Stripe subscription ID: {$stripeSubscriptionId}"); } } protected function handleSubscriptionPaymentFailure($stripeSubscriptionId) { $localSubscription = Subscription::where('gateway_subscription_id', $stripeSubscriptionId)->first(); if ($localSubscription) { // Stripe might set status to 'past_due' or 'unpaid' // You might want to fetch the latest status from Stripe API if not provided directly in event if (!setting('stripe_secret_key')) { Log::error('Stripe secret key not set for fetching subscription status on payment failure.'); return; } \Stripe\Stripe::setApiKey(setting('stripe_secret_key')); try { $stripeSub = StripeSubscriptionObject::retrieve($stripeSubscriptionId); $localSubscription->status = $stripeSub->status; // e.g., 'past_due' $localSubscription->save(); Log::info("Stripe Webhook: Updated local subscription ID {$localSubscription->id} to status {$stripeSub->status} due to payment failure."); // TODO: Notify user about payment failure } catch (\Exception $e) { Log::error("Stripe Webhook: Error fetching Stripe subscription {$stripeSubscriptionId} on payment failure: " . $e->getMessage()); } } else { Log::warning("Stripe Webhook: Received payment failure for unknown Stripe subscription ID: {$stripeSubscriptionId}"); } } } --- File: D:\projects\digitalvocano\Modules\StripeGateway\app\Providers\EventServiceProvider.php --- > */ protected $listen = []; /** * Indicates if events should be discovered. * * @var bool */ protected static $shouldDiscoverEvents = true; /** * Configure the proper event listeners for email verification. */ protected function configureEmailVerification(): void {} } --- File: D:\projects\digitalvocano\Modules\StripeGateway\app\Providers\RouteServiceProvider.php --- mapApiRoutes(); $this->mapWebRoutes(); // $this->mapAdminRoutes(); // <--- THIS LINE SHOULD BE COMMENTED OUT OR REMOVED } protected function mapWebRoutes(): void { Route::middleware('web') ->group(module_path($this->name, '/Routes/web.php')); } protected function mapApiRoutes(): void { Route::middleware('api') ->prefix('api') // Or 'api/' . strtolower($this->name) if you want module-specific API prefixes ->name('api.') // Or 'api.' . strtolower($this->name) . '.' ->group(module_path($this->name, '/Routes/api.php')); } // This entire method should be removed or its call in map() removed // if the main app's RouteServiceProvider handles module admin routes. /* protected function mapAdminRoutes(): void { Route::middleware(['web', \App\Http\Middleware\IsAdminMiddleware::class]) ->prefix('admin/' . strtolower($this->name)) ->name('admin.' . strtolower($this->name) . '.') ->group(module_path($this->name, '/Routes/admin.php')); } */ } --- File: D:\projects\digitalvocano\Modules\StripeGateway\app\Providers\StripeGatewayServiceProvider.php --- registerConfig(); $this->loadMigrationsFrom(module_path($this->moduleName, 'Database/Migrations')); // $this->registerTranslations(); // Add if you have them // $this->registerViews(); // Add if you have them // Route loading is handled by the main app's RouteServiceProvider loop for modules. // $this->loadRoutesFrom(module_path($this->moduleName, 'Routes/web.php')); // $this->loadRoutesFrom(module_path($this->moduleName, 'Routes/admin.php')); // Note: path should be Routes/admin.php // $this->loadRoutesFrom(module_path($this->moduleName, 'Routes/api.php')); } public function register(): void { // Route registration is handled by the main app's RouteServiceProvider loop for modules. // if (file_exists(module_path($this->moduleName, 'Providers/RouteServiceProvider.php'))) { // $this->app->register(module_path($this->moduleName, 'Providers/RouteServiceProvider.php')); // } } protected function registerConfig(): void { $this->publishes([ module_path($this->moduleName, 'config/config.php') => config_path($this->moduleNameLower . '.php'), ], 'config'); $this->mergeConfigFrom( module_path($this->moduleName, 'config/config.php'), $this->moduleNameLower ); } public function provides(): array { return []; } } --- File: D:\projects\digitalvocano\Modules\StripeGateway\config\config.php --- 'StripeGateway', ]; --- File: D:\projects\digitalvocano\Modules\StripeGateway\database\seeders\StripeGatewayDatabaseSeeder.php --- call([]); } } --- File: D:\projects\digitalvocano\Modules\StripeGateway\Http\Controllers\Admin\StripeConfigController.php --- route('admin.dashboard')->with('error', 'Settings helper function not found.'); } $settings = []; foreach ($this->settingKeys as $key) { $settings[$key] = setting($key); } return view('stripegateway::admin.config', compact('settings')); } public function update(Request $request) { if (!function_exists('setting')) { return redirect()->route('admin.dashboard')->with('error', 'Settings helper function not found.'); } $validated = $request->validate([ 'stripe_enabled' => 'nullable|boolean', 'stripe_publishable_key' => 'nullable|string|max:255', 'stripe_secret_key' => 'nullable|string|max:255', 'stripe_webhook_secret' => 'nullable|string|max:255', ]); try { foreach ($this->settingKeys as $key) { $value = $request->has($key) ? ($key === 'stripe_enabled' ? (bool)$request->input($key) : $request->input($key)) : ($key === 'stripe_enabled' ? '0' : null); // For boolean 'stripe_enabled', ensure '0' or '1' is stored if it's a checkbox if ($key === 'stripe_enabled') { $valueToStore = $request->has('stripe_enabled') ? '1' : '0'; } else { $valueToStore = $request->input($key); } Setting::updateOrCreate( ['key' => $key], ['value' => $valueToStore] ); } // Clear cache for settings to take effect immediately Artisan::call('cache:clear'); Artisan::call('config:clear'); return redirect()->back()->with('success', 'Stripe settings updated successfully.'); } catch (\Exception $e) { Log::error('Error updating Stripe settings: ' . $e->getMessage()); return redirect()->back()->with('error', 'Failed to update Stripe settings. Please check the logs.'); } } } --- File: D:\projects\digitalvocano\Modules\StripeGateway\Http\Controllers\StripeGatewayController.php --- stripeService = $stripeService; } public function checkout(Request $request, SubscriptionPlan $plan) { if (setting('stripe_enabled', '0') != '1') { return redirect()->route('subscription.plans')->with('error', 'Stripe payments are currently disabled.'); } /** @var User $user */ $user = Auth::user(); // Check if user already has an active subscription to this plan or any plan // (You might want more sophisticated logic here, e.g., allow upgrades/downgrades) $activeSubscription = $user->subscriptions() ->whereIn('status', ['active', 'trialing']) ->first(); if ($activeSubscription) { // If they are trying to subscribe to the same plan they already have and it's active/trialing if ($activeSubscription->subscription_plan_id == $plan->id) { return redirect()->route('subscription.plans')->with('info', 'You are already subscribed to this plan.'); } // For now, prevent new subscriptions if one is active. // Later, you can implement upgrade/downgrade logic. return redirect()->route('subscription.plans')->with('error', 'You already have an active subscription. Please manage it from your profile.'); } try { $checkoutSession = $this->stripeService->createCheckoutSession($user, $plan); return redirect($checkoutSession->url); } catch (\Exception $e) { Log::error("Stripe Checkout Error for user {$user->id}, plan {$plan->id}: " . $e->getMessage()); return redirect()->route('subscription.plans')->with('error', 'Could not initiate Stripe checkout. ' . $e->getMessage()); } } public function handleSuccess(Request $request) { $sessionId = $request->query('session_id'); if (!$sessionId) { return redirect()->route('subscription.plans')->with('error', 'Invalid Stripe session.'); } try { if (!setting('stripe_secret_key')) { throw new \Exception('Stripe secret key is not configured.'); } \Stripe\Stripe::setApiKey(setting('stripe_secret_key')); $session = StripeCheckoutSession::retrieve($sessionId, ['expand' => ['subscription', 'line_items.data.price.product']]); $user = User::find($session->metadata->user_id); $plan = SubscriptionPlan::find($session->metadata->plan_id); if (!$user || !$plan) { Log::error("Stripe success: User or Plan not found from session metadata.", ['session_id' => $sessionId, 'metadata' => $session->metadata]); return redirect()->route('subscription.plans')->with('error', 'Subscription details could not be verified.'); } // Check if a subscription record already exists for this Stripe subscription ID // This can happen if the webhook processed it first $existingSubscription = Subscription::where('gateway_subscription_id', $session->subscription->id)->first(); if ($existingSubscription) { Log::info("Stripe success: Subscription already processed by webhook for Stripe sub ID: " . $session->subscription->id); // Potentially update status if it was pending, though webhook should handle this. if ($existingSubscription->status === 'pending') { $existingSubscription->status = $session->subscription->status; // e.g., 'active' or 'trialing' $existingSubscription->starts_at = $session->subscription->current_period_start ? \Carbon\Carbon::createFromTimestamp($session->subscription->current_period_start) : now(); $existingSubscription->ends_at = $session->subscription->current_period_end ? \Carbon\Carbon::createFromTimestamp($session->subscription->current_period_end) : null; if ($session->subscription->trial_end) { $existingSubscription->trial_ends_at = \Carbon\Carbon::createFromTimestamp($session->subscription->trial_end); } $existingSubscription->save(); } } else { // Create new local subscription record Subscription::create([ 'user_id' => $user->id, 'subscription_plan_id' => $plan->id, 'payment_gateway' => 'stripe', 'gateway_subscription_id' => $session->subscription->id, 'status' => $session->subscription->status, // e.g., 'active' or 'trialing' 'starts_at' => $session->subscription->current_period_start ? \Carbon\Carbon::createFromTimestamp($session->subscription->current_period_start) : now(), 'ends_at' => $session->subscription->current_period_end ? \Carbon\Carbon::createFromTimestamp($session->subscription->current_period_end) : null, 'trial_ends_at' => $session->subscription->trial_end ? \Carbon\Carbon::createFromTimestamp($session->subscription->trial_end) : null, ]); } // Update user's Stripe customer ID if it's not set (though StripeService should do this) if ($user && $session->customer && !$user->stripe_customer_id) { $user->stripe_customer_id = $session->customer; $user->save(); } return redirect()->route('dashboard')->with('success', 'Subscription successful! Welcome to ' . $plan->name . '.'); } catch (\Exception $e) { Log::error("Stripe Success Error: " . $e->getMessage(), ['session_id' => $sessionId]); return redirect()->route('subscription.plans')->with('error', 'There was an issue confirming your subscription: ' . $e->getMessage()); } } public function handleCancel(Request $request) { return redirect()->route('subscription.plans')->with('info', 'Your subscription process was cancelled.'); } public function redirectToCustomerPortal(Request $request) { /** @var User $user */ $user = Auth::user(); if (!$user->stripe_customer_id) { return redirect()->back()->with('error', 'Stripe customer profile not found.'); } try { $portalSession = $this->stripeService->createBillingPortalSession($user); return redirect($portalSession->url); } catch (\Exception $e) { Log::error("Stripe Billing Portal Error for user {$user->id}: " . $e->getMessage()); return redirect()->back()->with('error', 'Could not access the billing portal: ' . $e->getMessage()); } } public function cancelActiveSubscription(Request $request) { /** @var User $user */ $user = Auth::user(); $activeSubscription = $user->subscriptions() ->where('payment_gateway', 'stripe') ->whereIn('status', ['active', 'trialing']) ->orderBy('created_at', 'desc') ->first(); if (!$activeSubscription) { return redirect()->back()->with('info', 'No active Stripe subscription found to cancel.'); } try { $cancelledAtGateway = $this->stripeService->cancelSubscriptionAtGateway($activeSubscription); if ($cancelledAtGateway) { // Webhook should ideally handle the final status update, // but we can mark it as 'cancelled' locally for immediate feedback. $activeSubscription->status = 'cancelled'; // Or 'pending_cancellation' $activeSubscription->cancelled_at = now(); // ends_at will be updated by webhook when Stripe confirms cancellation at period end $activeSubscription->save(); return redirect()->back()->with('success', 'Your subscription has been scheduled for cancellation at the end of the current billing period.'); } else { return redirect()->back()->with('error', 'Failed to cancel subscription at Stripe. Please try again or contact support.'); } } catch (\Exception $e) { Log::error("Error cancelling active Stripe subscription for user {$user->id}, sub ID {$activeSubscription->gateway_subscription_id}: " . $e->getMessage()); return redirect()->back()->with('error', 'An error occurred while trying to cancel your subscription: ' . $e->getMessage()); } } } --- File: D:\projects\digitalvocano\Modules\StripeGateway\Http\Controllers\StripeWebhookController.php --- stripeService = $stripeService; } public function handleWebhook(Request $request) { $payload = $request->getContent(); $sigHeader = $request->header('Stripe-Signature'); $event = null; try { $event = $this->stripeService->verifyWebhookSignature($payload, $sigHeader); } catch (\UnexpectedValueException $e) { // Invalid payload Log::error('Stripe Webhook Error: Invalid payload.', ['exception' => $e->getMessage()]); return response()->json(['error' => 'Invalid payload'], 400); } catch (\Stripe\Exception\SignatureVerificationException $e) { // Invalid signature Log::error('Stripe Webhook Error: Invalid signature.', ['exception' => $e->getMessage()]); return response()->json(['error' => 'Invalid signature'], 400); } catch (\Exception $e) { Log::error('Stripe Webhook Error: Could not verify signature.', ['exception' => $e->getMessage()]); return response()->json(['error' => 'Signature verification error'], 400); } // Handle the event switch ($event->type) { case 'checkout.session.completed': $session = $event->data->object; // contains a \Stripe\Checkout\Session // This is often handled by the success redirect, but good to have a fallback // or if you need to do something specific when checkout is fully completed. // Ensure you get the subscription ID from the session: $session->subscription Log::info('Stripe Webhook: checkout.session.completed', ['session_id' => $session->id, 'subscription_id' => $session->subscription]); if ($session->subscription && $session->payment_status == 'paid') { $this->handleSubscriptionUpdate($session->subscription, $session->customer); } break; case 'customer.subscription.created': case 'customer.subscription.updated': case 'customer.subscription.resumed': // Stripe specific for resumed subscriptions $stripeSubscription = $event->data->object; // contains a \Stripe\Subscription Log::info('Stripe Webhook: ' . $event->type, ['subscription_id' => $stripeSubscription->id]); $this->handleSubscriptionUpdate($stripeSubscription->id, $stripeSubscription->customer); break; case 'customer.subscription.trial_will_end': $stripeSubscription = $event->data->object; Log::info('Stripe Webhook: customer.subscription.trial_will_end', ['subscription_id' => $stripeSubscription->id]); // Send a notification to the user break; case 'customer.subscription.deleted': // Occurs when a subscription is canceled immediately or at period end. $stripeSubscription = $event->data->object; // contains a \Stripe\Subscription Log::info('Stripe Webhook: customer.subscription.deleted', ['subscription_id' => $stripeSubscription->id]); $this->handleSubscriptionCancellation($stripeSubscription->id); break; case 'invoice.payment_succeeded': $invoice = $event->data->object; // contains an \Stripe\Invoice Log::info('Stripe Webhook: invoice.payment_succeeded', ['invoice_id' => $invoice->id, 'subscription_id' => $invoice->subscription]); if ($invoice->subscription) { // This event confirms a recurring payment was successful. // The customer.subscription.updated event usually handles the date changes. $this->handleSubscriptionUpdate($invoice->subscription, $invoice->customer); } break; case 'invoice.payment_failed': $invoice = $event->data->object; // contains an \Stripe\Invoice Log::error('Stripe Webhook: invoice.payment_failed', ['invoice_id' => $invoice->id, 'subscription_id' => $invoice->subscription]); if ($invoice->subscription) { $this->handleSubscriptionPaymentFailure($invoice->subscription); } break; // ... handle other event types default: Log::info('Stripe Webhook: Received unhandled event type ' . $event->type); } return response()->json(['status' => 'success']); } protected function handleSubscriptionUpdate($stripeSubscriptionId, $stripeCustomerId) { try { if (!setting('stripe_secret_key')) { throw new \Exception('Stripe secret key is not configured for webhook processing.'); } \Stripe\Stripe::setApiKey(setting('stripe_secret_key')); $stripeSubscription = StripeSubscriptionObject::retrieve($stripeSubscriptionId); // Use aliased StripeSubscriptionObject $localSubscription = Subscription::where('gateway_subscription_id', $stripeSubscription->id)->first(); $user = User::where('stripe_customer_id', $stripeCustomerId)->first(); if (!$user && $localSubscription) { // Fallback if customer ID wasn't set on user yet $user = $localSubscription->user; } if (!$user) { Log::error("Stripe Webhook: User not found for Stripe Customer ID: {$stripeCustomerId}"); return; } // Ensure user has stripe_customer_id if it's missing if ($user && $stripeCustomerId && !$user->stripe_customer_id) { $user->stripe_customer_id = $stripeCustomerId; $user->save(); } $plan = null; if (isset($stripeSubscription->items->data[0]->price->product)) { // This assumes you store Stripe Product ID or a mapping if your plan slugs don't match Stripe Product IDs // For simplicity, let's assume plan_id was stored in metadata or you can find it // A more robust way is to store Stripe Price ID on your SubscriptionPlan model. // For now, we'll rely on existing local subscription or metadata if available. } if (!$plan && $localSubscription) { $plan = $localSubscription->plan; } // If creating a new subscription from webhook (e.g. checkout.session.completed without success redirect hitting first) // You'd need to get plan_id from $stripeSubscription->metadata or items. // For now, this primarily updates existing subscriptions. if ($localSubscription) { $localSubscription->status = $stripeSubscription->status; $localSubscription->starts_at = \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_start); $localSubscription->ends_at = \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end); $localSubscription->trial_ends_at = $stripeSubscription->trial_end ? \Carbon\Carbon::createFromTimestamp($stripeSubscription->trial_end) : null; $localSubscription->cancelled_at = $stripeSubscription->cancel_at_period_end ? ($stripeSubscription->canceled_at ? \Carbon\Carbon::createFromTimestamp($stripeSubscription->canceled_at) : now()) : null; $localSubscription->save(); Log::info("Stripe Webhook: Updated local subscription ID {$localSubscription->id} to status {$stripeSubscription->status}"); } elseif ($user && $plan) { // Create if it doesn't exist (e.g. from checkout.session.completed) Subscription::create([ 'user_id' => $user->id, 'subscription_plan_id' => $plan->id, // This needs to be reliably determined 'payment_gateway' => 'stripe', 'gateway_subscription_id' => $stripeSubscription->id, 'status' => $stripeSubscription->status, 'starts_at' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_start), 'ends_at' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end), 'trial_ends_at' => $stripeSubscription->trial_end ? \Carbon\Carbon::createFromTimestamp($stripeSubscription->trial_end) : null, ]); Log::info("Stripe Webhook: Created new local subscription for user {$user->id}, Stripe sub ID {$stripeSubscription->id}"); } else { Log::warning("Stripe Webhook: Could not create or update local subscription for Stripe sub ID {$stripeSubscription->id}. User or Plan missing."); } } catch (\Exception $e) { Log::error("Stripe Webhook handleSubscriptionUpdate Error for sub ID {$stripeSubscriptionId}: " . $e->getMessage()); } } protected function handleSubscriptionCancellation($stripeSubscriptionId) { $localSubscription = Subscription::where('gateway_subscription_id', $stripeSubscriptionId)->first(); if ($localSubscription) { $localSubscription->status = 'cancelled'; // Or 'ended' depending on Stripe's final status $localSubscription->cancelled_at = now(); // ends_at might already be set if it was cancel_at_period_end if (!$localSubscription->ends_at || $localSubscription->ends_at->isFuture()) { // If Stripe cancels it immediately, set ends_at to now. // If it was cancel_at_period_end, ends_at should reflect that. // The customer.subscription.updated event with status 'canceled' often provides the final end date. } $localSubscription->save(); Log::info("Stripe Webhook: Cancelled local subscription ID {$localSubscription->id}"); } else { Log::warning("Stripe Webhook: Received cancellation for unknown Stripe subscription ID: {$stripeSubscriptionId}"); } } protected function handleSubscriptionPaymentFailure($stripeSubscriptionId) { $localSubscription = Subscription::where('gateway_subscription_id', $stripeSubscriptionId)->first(); if ($localSubscription) { // Stripe might set status to 'past_due' or 'unpaid' // You might want to fetch the latest status from Stripe API if not provided directly in event if (!setting('stripe_secret_key')) { Log::error('Stripe secret key not set for fetching subscription status on payment failure.'); return; } \Stripe\Stripe::setApiKey(setting('stripe_secret_key')); try { $stripeSub = StripeSubscriptionObject::retrieve($stripeSubscriptionId); $localSubscription->status = $stripeSub->status; // e.g., 'past_due' $localSubscription->save(); Log::info("Stripe Webhook: Updated local subscription ID {$localSubscription->id} to status {$stripeSub->status} due to payment failure."); // TODO: Notify user about payment failure } catch (\Exception $e) { Log::error("Stripe Webhook: Error fetching Stripe subscription {$stripeSubscriptionId} on payment failure: " . $e->getMessage()); } } else { Log::warning("Stripe Webhook: Received payment failure for unknown Stripe subscription ID: {$stripeSubscriptionId}"); } } } --- File: D:\projects\digitalvocano\Modules\StripeGateway\Providers\EventServiceProvider.php --- > */ protected $listen = []; /** * Indicates if events should be discovered. * * @var bool */ protected static $shouldDiscoverEvents = true; /** * Configure the proper event listeners for email verification. */ protected function configureEmailVerification(): void {} } --- File: D:\projects\digitalvocano\Modules\StripeGateway\Providers\RouteServiceProvider.php --- mapWebRoutes(); // $this->mapAdminRoutes(); // Admin routes are loaded by the main app's RouteServiceProvider $this->mapApiRoutes(); } /** * Define the "web" routes for the application. * * These routes all receive session state, CSRF protection, etc. */ protected function mapWebRoutes(): void { // The main app's RouteServiceProvider already applies the 'web' middleware. // This ensures controllers in web.php are correctly namespaced. Route::middleware('web') // Ensures the group call is valid and context is clear ->namespace($this->moduleNamespace) ->group(module_path($this->name, '/routes/web.php')); // Use lowercase 'routes' } /** * Define the "admin" routes for the application. */ // protected function mapAdminRoutes(): void // This method is not needed as main RSP handles admin routes // { // The main app's RouteServiceProvider handles loading admin routes, // applying 'web' and 'IsAdminMiddleware', prefixing with 'admin/stripegateway', // naming with 'admin.stripegateway.', and namespacing to 'Modules\StripeGateway\Http\Controllers\Admin'. // } /** * Define the "api" routes for the application. */ protected function mapApiRoutes(): void { // The main app's RouteServiceProvider applies 'api' middleware, // 'api/stripegateway' prefix, 'api.stripegateway.' name prefix, // and the base 'Modules\StripeGateway\Http\Controllers' namespace. // This method ensures controllers in api.php are correctly namespaced // relative to $this->moduleNamespace. Route::namespace($this->moduleNamespace) // Assuming API controllers are directly under Http/Controllers ->group(module_path($this->name, '/routes/api.php')); // Use lowercase 'routes' } } --- File: D:\projects\digitalvocano\Modules\StripeGateway\Providers\StripeGatewayServiceProvider.php --- registerConfig(); $this->loadMigrationsFrom(module_path($this->moduleName, 'Database/Migrations')); $this->registerTranslations(); // Uncomment and implement $this->registerViews(); // Uncomment and implement // Route loading is handled by the main app's RouteServiceProvider loop for modules. // $this->loadRoutesFrom(module_path($this->moduleName, 'Routes/web.php')); // $this->loadRoutesFrom(module_path($this->moduleName, 'Routes/admin.php')); // Note: path should be Routes/admin.php // $this->loadRoutesFrom(module_path($this->moduleName, 'Routes/api.php')); } // In Modules\StripeGateway\Providers\StripeGatewayServiceProvider.php public function register(): void { $this->app->register(RouteServiceProvider::class); } protected function registerConfig(): void { $this->publishes([ module_path($this->moduleName, 'config/config.php') => config_path($this->moduleNameLower . '.php'), ], 'config'); $this->mergeConfigFrom( module_path($this->moduleName, 'config/config.php'), $this->moduleNameLower ); } public function registerViews(): void { $viewPath = resource_path('views/modules/'.$this->moduleNameLower); $sourcePath = module_path($this->moduleName, 'resources/views'); $this->publishes([$sourcePath => $viewPath], ['views', $this->moduleNameLower.'-module-views']); $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->moduleNameLower); Blade::componentNamespace(config('modules.namespace').'\\' . $this->moduleName . '\\View\\Components', $this->moduleNameLower); } public function registerTranslations(): void { $langPath = resource_path('lang/modules/' . $this->moduleNameLower); if (is_dir($langPath)) { $this->loadTranslationsFrom($langPath, $this->moduleNameLower); $this->loadJsonTranslationsFrom($langPath); } else { $this->loadTranslationsFrom(module_path($this->moduleName, 'lang'), $this->moduleNameLower); $this->loadJsonTranslationsFrom(module_path($this->moduleName, 'lang')); } } public function provides(): array { return []; } private function getPublishableViewPaths(): array { $paths = []; foreach (config('view.paths') as $path) { if (is_dir($path.'/modules/'.$this->moduleNameLower)) { $paths[] = $path.'/modules/'.$this->moduleNameLower; } } return $paths; } } --- File: D:\projects\digitalvocano\Modules\StripeGateway\resources\views\admin\config.blade.php --- @extends('layouts.admin') {{-- Use your admin layout --}} @section('title', 'Stripe Gateway Settings') @section('header_title', 'Stripe Gateway Configuration') @section('content')
Module: {!! config('stripegateway.name') !!}