--- File: D:\projects\digitalvocano\Modules\PaystackGateway\app\Http\Controllers\Admin\PaystackConfigController.php --- route('admin.dashboard')->with('error', 'Settings helper function not found.'); } $settings = []; foreach ($this->settingKeys as $key) { $settings[$key] = setting($key); } $settings['paystack_mode'] = $settings['paystack_mode'] ?? 'test'; // Default to test return view('paystackgateway::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([ 'paystack_enabled' => 'nullable|boolean', 'paystack_public_key' => 'nullable|string|max:255', 'paystack_secret_key' => 'nullable|string|max:255', 'paystack_mode' => 'required|in:test,live', ]); try { foreach ($this->settingKeys as $key) { $valueToStore = null; if ($key === 'paystack_enabled') { $valueToStore = $request->has('paystack_enabled') ? '1' : '0'; } elseif ($request->filled($key)) { $valueToStore = $request->input($key); } Setting::updateOrCreate(['key' => $key], ['value' => $valueToStore]); } Artisan::call('cache:clear'); Artisan::call('config:clear'); return redirect()->back()->with('success', 'Paystack settings updated successfully.'); } catch (\Exception $e) { Log::error('Error updating Paystack settings: ' . $e->getMessage()); return redirect()->back()->with('error', 'Failed to update Paystack settings. Please check the logs.'); } } } --- File: D:\projects\digitalvocano\Modules\PaystackGateway\app\Http\Controllers\PaystackGatewayController.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\PaystackGateway\app\Providers\PaystackGatewayServiceProvider.php --- registerCommands(); $this->registerCommandSchedules(); $this->registerTranslations(); $this->registerConfig(); $this->registerViews(); $this->loadMigrationsFrom(module_path($this->name, 'database/migrations')); } /** * Register the service provider. */ public function register(): void { $this->app->register(EventServiceProvider::class); $this->app->register(RouteServiceProvider::class); } /** * Register commands in the format of Command::class */ protected function registerCommands(): void { // $this->commands([]); } /** * Register command Schedules. */ protected function registerCommandSchedules(): void { // $this->app->booted(function () { // $schedule = $this->app->make(Schedule::class); // $schedule->command('inspire')->hourly(); // }); } /** * Register translations. */ public function registerTranslations(): void { $langPath = resource_path('lang/modules/'.$this->nameLower); if (is_dir($langPath)) { $this->loadTranslationsFrom($langPath, $this->nameLower); $this->loadJsonTranslationsFrom($langPath); } else { $this->loadTranslationsFrom(module_path($this->name, 'lang'), $this->nameLower); $this->loadJsonTranslationsFrom(module_path($this->name, 'lang')); } } /** * Register config. */ protected function registerConfig(): void { $configPath = module_path($this->name, config('modules.paths.generator.config.path')); if (is_dir($configPath)) { $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath)); foreach ($iterator as $file) { if ($file->isFile() && $file->getExtension() === 'php') { $config = str_replace($configPath.DIRECTORY_SEPARATOR, '', $file->getPathname()); $config_key = str_replace([DIRECTORY_SEPARATOR, '.php'], ['.', ''], $config); $segments = explode('.', $this->nameLower.'.'.$config_key); // Remove duplicated adjacent segments $normalized = []; foreach ($segments as $segment) { if (end($normalized) !== $segment) { $normalized[] = $segment; } } $key = ($config === 'config.php') ? $this->nameLower : implode('.', $normalized); $this->publishes([$file->getPathname() => config_path($config)], 'config'); $this->merge_config_from($file->getPathname(), $key); } } } } /** * Merge config from the given path recursively. */ protected function merge_config_from(string $path, string $key): void { $existing = config($key, []); $module_config = require $path; config([$key => array_replace_recursive($existing, $module_config)]); } /** * Register views. */ public function registerViews(): void { $viewPath = resource_path('views/modules/'.$this->nameLower); $sourcePath = module_path($this->name, 'resources/views'); $this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']); $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->nameLower); Blade::componentNamespace(config('modules.namespace').'\\' . $this->name . '\\View\\Components', $this->nameLower); } /** * Get the services provided by the provider. */ public function provides(): array { return []; } private function getPublishableViewPaths(): array { $paths = []; foreach (config('view.paths') as $path) { if (is_dir($path.'/modules/'.$this->nameLower)) { $paths[] = $path.'/modules/'.$this->nameLower; } } return $paths; } } --- File: D:\projects\digitalvocano\Modules\PaystackGateway\app\Providers\RouteServiceProvider.php --- mapApiRoutes(); $this->mapWebRoutes(); } /** * Define the "web" routes for the application. * * These routes all receive session state, CSRF protection, etc. */ protected function mapWebRoutes(): void { Route::middleware('web')->group(module_path($this->name, '/routes/web.php')); } /** * Define the "api" routes for the application. * * These routes are typically stateless. */ protected function mapApiRoutes(): void { Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php')); } } --- File: D:\projects\digitalvocano\Modules\PaystackGateway\config\config.php --- 'PaystackGateway', ]; --- File: D:\projects\digitalvocano\Modules\PaystackGateway\database\seeders\PaystackGatewayDatabaseSeeder.php --- call([]); } } --- File: D:\projects\digitalvocano\Modules\PaystackGateway\Http\Controllers\Admin\PaystackConfigController.php --- route('admin.dashboard')->with('error', 'Settings helper function not found.'); } $settings = []; foreach ($this->settingKeys as $key) { $settings[$key] = setting($key); } $settings['paystack_mode'] = $settings['paystack_mode'] ?? 'test'; // Default to test // Default enabled to '0' if not set if (is_null($settings['paystack_enabled'])) { $settings['paystack_enabled'] = '0'; } return view('paystackgateway::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([ 'paystack_enabled' => 'nullable|boolean', 'paystack_public_key' => 'nullable|string|max:255', 'paystack_secret_key' => 'nullable|string|max:255', 'paystack_mode' => 'required|in:test,live', ]); try { // Define settings with their names, groups, and types $settingsData = [ 'paystack_enabled' => [ 'value' => $request->input('paystack_enabled', '0'), 'name' => 'Enable Paystack Gateway', 'group' => 'Payment Gateways', 'type' => 'boolean' ], 'paystack_mode' => [ 'value' => $request->input('paystack_mode', 'test'), 'name' => 'Paystack Mode', 'group' => 'Payment Gateways', 'type' => 'select' ], 'paystack_public_key' => [ 'value' => $request->input('paystack_public_key'), 'name' => 'Paystack Public Key', 'group' => 'Payment Gateways', 'type' => 'text' ], // Secret key is handled separately ]; foreach ($settingsData as $key => $data) { Setting::setValue($key, $data['value'], $data['name'], $data['group'], $data['type']); } // Handle secret key: only update if a new value is provided if ($request->filled('paystack_secret_key')) { Setting::setValue('paystack_secret_key', $request->input('paystack_secret_key'), 'Paystack Secret Key', 'Payment Gateways', 'password'); } Artisan::call('cache:clear'); Artisan::call('config:clear'); return redirect()->back()->with('success', 'Paystack settings updated successfully.'); } catch (\Exception $e) { Log::error('Error updating Paystack settings: ' . $e->getMessage()); return redirect()->back()->with('error', 'Failed to update Paystack settings. Please check the logs.'); } } } --- File: D:\projects\digitalvocano\Modules\PaystackGateway\Http\Controllers\PaystackGatewayController.php --- paystackService = $paystackService; $this->creditService = $creditService; $this->walletService = $walletService; // Added } public function initializePayment(Request $request, SubscriptionPlan $plan) { // --- Test with a Direct Eloquent Query --- $slugFromRoute = $request->route('subscriptionPlan'); // Get the raw slug or resolved model if (is_object($slugFromRoute) && $slugFromRoute instanceof SubscriptionPlan) { // If RMB resolved it (even to an empty model), get the slug it tried to use $slugValueToQuery = $slugFromRoute->slug ?? $request->segment(count($request->segments())); } else { $slugValueToQuery = (string) $slugFromRoute; } $directPlan = \App\Models\SubscriptionPlan::where('slug', $slugValueToQuery)->first(); Log::debug("Paystack Initialize: Direct Eloquent query for slug '{$slugValueToQuery}'", [ 'found_plan_id_direct_query' => $directPlan ? $directPlan->id : 'NOT FOUND BY DIRECT QUERY', 'direct_plan_attributes' => $directPlan ? $directPlan->getAttributes() : null, ]); // --- End Test with a Direct Eloquent Query --- // Log the incoming plan object immediately to see what Route Model Binding resolved if (!$plan->exists) { // Check if the model instance is "empty" (not found by RMB) Log::error("Paystack Initialize: Route Model Binding failed to find a SubscriptionPlan for the provided slug.", [ 'route_param_slug_from_request' => $request->route('subscriptionPlan'), 'plan_object_class_on_failure' => get_class($plan) // Log the class of the $plan object ]); // Optionally, redirect or abort if plan not found, as further processing will fail. return redirect()->route('subscription.plans')->with('error', 'The selected subscription plan could not be found.'); } Log::debug("Paystack Initialize: Entered initializePayment method.", [ 'route_param_slug' => $request->route('subscriptionPlan') instanceof SubscriptionPlan ? $request->route('subscriptionPlan')->slug : $request->route('subscriptionPlan'), // Get the raw slug from route 'plan_id_resolved' => $plan->id ?? 'PLAN ID IS NULL', 'plan_slug_resolved' => $plan->slug ?? 'PLAN SLUG IS NULL', 'plan_name_resolved' => $plan->name ?? 'PLAN NAME IS NULL', 'plan_price_resolved' => $plan->price ?? 'PLAN PRICE IS NULL', 'plan_currency_resolved' => $plan->currency ?? 'PLAN CURRENCY IS NULL', 'is_plan_model_empty' => $plan->exists === false, // Check if it's an empty model instance 'plan_object_attributes' => $plan ? $plan->getAttributes() : null ]); if (setting('paystack_enabled', '0') != '1') { return redirect()->route('subscription.plans')->with('error', 'Paystack payments are currently disabled.'); } /** @var User $user */ $user = Auth::user(); // Check for existing active subscription (similar to PayPal) $activeSubscription = $user->currentSubscription(); if ($activeSubscription) { if ($activeSubscription->subscription_plan_id == $plan->id && $activeSubscription->payment_gateway == 'paystackgateway') { return redirect()->route('subscription.plans')->with('info', 'You are already subscribed to this plan via Paystack.'); } elseif ($activeSubscription->subscription_plan_id != $plan->id) { // User has an active subscription to a DIFFERENT plan. Allow them to proceed. // Inform them that their current plan will be cancelled if the new subscription is successful. session()->flash('info_plan_switch', "You are switching from '{$activeSubscription->plan->name}' to '{$plan->name}'. Your current plan will be cancelled upon successful activation of the new plan."); } } try { // $amountInKobo = $plan->price * 100; // Paystack service will handle amount based on plan // Create a pending subscription record $pendingSubscription = null; $subscriptionDataToCreate = [ 'user_id' => $user->id, 'subscription_plan_id' => $plan->id, 'payment_gateway' => 'paystackgateway', 'gateway_transaction_id' => 'TEMP_PAYSTACK_INIT-' . Str::random(12) . '-' . time(), // Temporary initial reference 'status' => 'pending_payment', 'price_at_purchase' => $plan->price, 'currency_at_purchase' => $plan->currency, ]; Log::debug("Paystack Initialize: Attempting to create Subscription with data:", $subscriptionDataToCreate); try { $pendingSubscription = Subscription::create($subscriptionDataToCreate); } catch (\Illuminate\Database\QueryException $qe) { Log::critical("Paystack Initialize: DATABASE QUERY EXCEPTION during Subscription::create()", [ 'temp_reference_attempted' => $subscriptionDataToCreate['gateway_transaction_id'], 'user_id' => $user->id, 'plan_id' => $plan->id, 'error' => $qe->getMessage(), 'sql' => $qe->getSql(), 'bindings' => $qe->getBindings(), 'trace' => method_exists(Str::class, 'limit') ? Str::limit($qe->getTraceAsString(), 1000) : substr($qe->getTraceAsString(), 0, 1000) ]); return redirect()->route('subscription.plans')->with('error', 'Critical error: Failed to initialize your subscription record due to a database issue. Please contact support. (Ref: INIT_DB_EX)'); } catch (\Exception $e) { Log::critical("Paystack Initialize: GENERAL EXCEPTION during Subscription::create()", [ 'temp_reference_attempted' => $subscriptionDataToCreate['gateway_transaction_id'], 'user_id' => $user->id, 'plan_id' => $plan->id, 'error' => $e->getMessage(), 'trace' => method_exists(Str::class, 'limit') ? Str::limit($e->getTraceAsString(), 1000) : substr($e->getTraceAsString(), 0, 1000) ]); return redirect()->route('subscription.plans')->with('error', 'Critical error: Failed to initialize your subscription record. Please contact support. (Ref: INIT_GEN_EX)'); } if (!$pendingSubscription || !$pendingSubscription->id) { Log::error("Paystack Initialize: FAILED to create pending subscription record (Subscription::create() returned null or invalid object).", [ 'temp_reference_attempted' => $subscriptionDataToCreate['gateway_transaction_id'], 'user_id' => $user->id, 'plan_id' => $plan->id, 'data_attempted' => $subscriptionDataToCreate ]); return redirect()->route('subscription.plans')->with('error', 'Failed to initialize your subscription record. Please try again or contact support. (Ref: POST_CREATE_NULL)'); } else { $createdSubscriptionId = $pendingSubscription->id; // Store ID for re-fetch Log::info("Paystack Initialize: Successfully CREATED pending subscription record in DB.", [ 'id' => $pendingSubscription->id, 'initial_temp_reference_stored' => $pendingSubscription->gateway_transaction_id, 'user_id' => $pendingSubscription->user_id, 'plan_id' => $pendingSubscription->subscription_plan_id, 'status_stored' => $pendingSubscription->status ]); } // Call the service method for initializing a SUBSCRIPTION transaction, // passing the ID of our local pending subscription. $paymentDetails = $this->paystackService->initializeSubscriptionTransaction($user, $plan, $pendingSubscription->id); if ($paymentDetails && !empty($paymentDetails['authorization_url']) && !empty($paymentDetails['reference'])) { // Update the local subscription with the actual reference from Paystack $pendingSubscription->gateway_transaction_id = $paymentDetails['reference']; if ($pendingSubscription->save()) { Log::info("Paystack Initialize: Successfully updated local subscription with Paystack's reference.", [ 'local_subscription_id' => $pendingSubscription->id, 'paystack_reference' => $paymentDetails['reference'] ]); } else { Log::error("Paystack Initialize: FAILED to SAVE Paystack's reference to local subscription.", [ 'local_subscription_id' => $pendingSubscription->id, 'attempted_paystack_reference' => $paymentDetails['reference'] ]); $pendingSubscription->update(['status' => 'failed']); // Mark as failed return redirect()->route('subscription.plans')->with('error', 'Failed to record payment reference. Please contact support.'); } return redirect()->away($paymentDetails['authorization_url']); } Log::error('Paystack initialize subscription transaction failed or missing data from service.', ['response_from_service' => $paymentDetails, 'user_id' => $user->id, 'plan_id' => $plan->id]); $pendingSubscription->update(['status' => 'failed']); // Mark as failed return redirect()->route('subscription.plans')->with('error', 'Could not initiate Paystack payment. Please try again.'); } catch (\Exception $e) { Log::error("Paystack Initialize Payment Error for user {$user->id}, plan {$plan->id}: " . $e->getMessage()); return redirect()->route('subscription.plans')->with('error', 'An error occurred while initiating payment: ' . $e->getMessage()); } } public function handleCallback(Request $request) { $reference = $request->query('reference'); if (!$reference) { return redirect()->route('subscription.plans')->with('error', 'Invalid Paystack callback. Reference missing.'); } try { $verificationData = $this->paystackService->verifyTransaction($reference); if ($verificationData && isset($verificationData['status']) && $verificationData['status'] === true && isset($verificationData['data']['status']) && $verificationData['data']['status'] === 'success') { $paystackData = $verificationData['data']; $metadata = $paystackData['metadata'] ?? []; $localSubscriptionIdFromMeta = $metadata['subscription_id'] ?? null; $referenceFromQuery = $request->query('reference'); // This is the $reference used for verification $subscription = null; if ($localSubscriptionIdFromMeta) { $subscription = Subscription::find($localSubscriptionIdFromMeta); } // Diagnostic: Check if ANY subscription exists with this reference, regardless of status if (!$subscription && $referenceFromQuery) { $anySubscriptionWithReference = Subscription::where('gateway_transaction_id', $referenceFromQuery) ->where('payment_gateway', 'paystackgateway') ->first(); if ($anySubscriptionWithReference) { Log::info("Paystack Callback: A subscription (ID: {$anySubscriptionWithReference->id}) was found with reference '{$referenceFromQuery}', but its status is '{$anySubscriptionWithReference->status}'. Expected 'pending_payment'."); } else { Log::info("Paystack Callback: NO subscription record found at all with reference '{$referenceFromQuery}'."); } } // If not found by metadata ID, or if metadata ID was not present, try finding by the reference // This assumes the reference was stored in gateway_transaction_id during initialization if (!$subscription && $referenceFromQuery) { Log::info("Paystack Callback: Subscription not found by metadata ID '{$localSubscriptionIdFromMeta}'. Attempting lookup by reference '{$referenceFromQuery}'."); $subscription = Subscription::where('gateway_transaction_id', $referenceFromQuery) ->where('payment_gateway', 'paystackgateway') // Ensure it's a Paystack sub ->where('status', 'pending_payment') // Specifically look for pending ->first(); } if ($subscription && $subscription->status === 'pending_payment') { // Ensure it's the correct pending subscription // Deactivate any other existing subscriptions for this user $subscription->user->subscriptions() ->where('id', '!=', $subscription->id) ->whereIn('status', ['active', 'trialing']) ->update(['status' => 'cancelled', 'ends_at' => now(), 'cancelled_at' => now()]); $subscription->updateFromPaystackData($paystackData); // Implement this method in Subscription model // Award credits (similar to SubscriptionController) $plan = $subscription->plan; if (function_exists('setting') && setting('credits_system_enabled', '0') == '1' && $plan->credits_awarded_on_purchase > 0) { $this->creditService->awardCredits($subscription->user, $plan->credits_awarded_on_purchase, 'award_subscription_purchase', "Credits for {$plan->name} subscription", $subscription); } // Assign target role if defined if (!empty($plan->target_role) && class_exists(\Spatie\Permission\Models\Role::class) && \Spatie\Permission\Models\Role::where('name', $plan->target_role)->where('guard_name', 'web')->exists()) { $subscription->user->syncRoles([$plan->target_role]); } return redirect()->route('dashboard')->with('success', 'Subscription successfully activated via Paystack!'); } Log::warning('Paystack Callback: Local subscription not found or not in a pending state.', [ 'reference' => $referenceFromQuery, 'local_id_from_meta' => $localSubscriptionIdFromMeta, 'subscription_found_id' => $subscription ? $subscription->id : null, 'status_if_found' => $subscription ? $subscription->status : 'N/A' ]); return redirect()->route('subscription.plans')->with('error', 'Subscription record mismatch or already processed. Please contact support.'); } Log::error('Paystack transaction verification failed or payment not successful.', ['reference' => $reference, 'response' => $verificationData]); return redirect()->route('subscription.plans')->with('error', $verificationData['message'] ?? 'Paystack payment verification failed.'); } catch (\Exception $e) { Log::error("Paystack Callback Error for reference {$reference}: " . $e->getMessage()); return redirect()->route('subscription.plans')->with('error', 'An error occurred during payment verification: ' . $e->getMessage()); } } // --- Wallet Deposit Methods --- public function initializeWalletDeposit(Request $request) { if (setting('paystack_enabled', '0') != '1' || setting('allow_wallet_deposits', '0') != '1') { return redirect()->route('user.wallet.deposit.form')->with('error', 'Paystack deposits are currently disabled.'); } $request->validate(['amount' => 'required|numeric|min:1']); $amount = (float) $request->input('amount'); /** @var User $user */ $user = Auth::user(); try { $reference = 'DEPOSIT-' . Str::random(8) . '-' . time(); $amountInKobo = $amount * 100; // Paystack expects amount in kobo $currency = setting('currency_code', 'NGN'); // Paystack often defaults to NGN // Optionally create a pending WalletTransaction record here if needed for tracking before redirect $callbackUrl = route('wallet.paystack.depositCallback'); $metadata = [ 'user_id' => $user->id, 'deposit_amount' => $amount, 'currency' => $currency, 'type' => 'wallet_deposit', 'custom_fields' => [ ['display_name' => "Wallet Deposit", 'variable_name' => "transaction_type", 'value' => "Wallet Top-up"] ] ]; $paymentData = $this->paystackService->initializeTransaction($amountInKobo, $user->email, $reference, $callbackUrl, $metadata); if ($paymentData && isset($paymentData['status']) && $paymentData['status'] === true && isset($paymentData['data']['authorization_url'])) { return redirect()->away($paymentData['data']['authorization_url']); } Log::error('Paystack initialize wallet deposit failed.', ['response' => $paymentData, 'user_id' => $user->id, 'amount' => $amount]); return redirect()->route('user.wallet.deposit.form')->with('error', $paymentData['message'] ?? 'Could not initiate Paystack deposit. Please try again.'); } catch (\Exception $e) { Log::error("Paystack Initialize Wallet Deposit Error for user {$user->id}, amount {$amount}: " . $e->getMessage()); return redirect()->route('user.wallet.deposit.form')->with('error', 'An error occurred while initiating deposit: ' . $e->getMessage()); } } public function handleWalletDepositCallback(Request $request) { $reference = $request->query('reference'); if (!$reference) { return redirect()->route('user.wallet.deposit.form')->with('error', 'Invalid Paystack callback for deposit. Reference missing.'); } try { $verificationData = $this->paystackService->verifyTransaction($reference); if ($verificationData && isset($verificationData['status']) && $verificationData['status'] === true && isset($verificationData['data']['status']) && $verificationData['data']['status'] === 'success') { $paystackData = $verificationData['data']; $amountDeposited = $paystackData['amount'] / 100; // Amount is in Kobo $currency = $paystackData['currency'] ?? 'NGN'; $userIdFromMeta = $paystackData['metadata']['user_id'] ?? null; if (!$userIdFromMeta) { Log::error('Paystack Wallet Deposit Callback: User ID missing from metadata.', ['reference' => $reference]); return redirect()->route('user.wallet.deposit.form')->with('error', 'User identification failed for deposit.'); } $user = User::find($userIdFromMeta); if (!$user) { Log::error('Paystack Wallet Deposit Callback: User not found for ID from metadata.', ['reference' => $reference, 'user_id_from_meta' => $userIdFromMeta]); return redirect()->route('user.wallet.deposit.form')->with('error', 'User account not found for deposit.'); } // User successfully identified from metadata $this->walletService->deposit($user, $amountDeposited, $currency, 'paystackgateway', $reference, "Wallet deposit via Paystack"); return redirect()->route('user.wallet.history')->with('success', 'Successfully deposited ' . $currency . ' ' . number_format($amountDeposited, 2) . ' to your wallet.'); } Log::error('Paystack wallet deposit verification failed or payment not successful.', ['reference' => $reference, 'response' => $verificationData]); return redirect()->route('user.wallet.deposit.form')->with('error', $verificationData['message'] ?? 'Paystack payment verification failed for deposit.'); } catch (\Exception $e) { Log::error("Paystack Wallet Deposit Callback Error for reference {$reference}: " . $e->getMessage()); return redirect()->route('user.wallet.deposit.form')->with('error', 'An error occurred during deposit verification: ' . $e->getMessage()); } } } --- File: D:\projects\digitalvocano\Modules\PaystackGateway\Http\Controllers\PaystackWebhookController.php --- paystackService = $paystackService; } public function handleWebhook(Request $request) { // 1. Verify the webhook signature $signature = $request->header('x-paystack-signature'); $rawBody = $request->getContent(); if (!$this->paystackService->secretKey || !$signature) { Log::warning('Paystack Webhook: Missing secret key or signature for verification.'); return response()->json(['status' => 'error', 'message' => 'Configuration error or missing signature'], 400); } $calculatedSignature = hash_hmac('sha512', $rawBody, $this->paystackService->secretKey); if (!hash_equals($calculatedSignature, $signature)) { Log::warning('Paystack Webhook: Invalid signature.', [ 'received_signature' => $signature, 'calculated_signature' => $calculatedSignature, 'ip_address' => $request->ip(), ]); return response()->json(['status' => 'error', 'message' => 'Signature verification failed'], 401); } // 2. Process the event $event = json_decode($rawBody); if (!$event || !isset($event->event)) { Log::error('Paystack Webhook: Invalid event data received or missing event type.'); return response()->json(['status' => 'error', 'message' => 'Invalid event data'], 400); } Log::info('Paystack Webhook Received:', ['event_type' => $event->event, 'data' => (array)($event->data ?? [])]); $eventType = $event->event; $data = $event->data ?? null; switch ($eventType) { case 'charge.success': // This is a crucial event for recurring payments or successful one-time charges. if ($data && isset($data->reference)) { // If it's related to a subscription, the metadata might contain subscription_id or plan_code. // You might need to fetch the local subscription using $data->reference or metadata. // For now, let's assume it might be a subscription payment. $this->handleSuccessfulCharge($data); } break; case 'subscription.create': // A new subscription is created on Paystack case 'subscription.enable': // A previously disabled subscription is re-enabled // You might want to update your local subscription status if it was, for example, 'pending' or 'disabled'. // $data->subscription_code, $data->customer->email, $data->plan->plan_code break; case 'subscription.disable': // Subscription is disabled (e.g., due to payment failure or manual action) // Update your local subscription status to 'cancelled' or 'suspended'. // $data->subscription_code break; // Add more Paystack event types as needed: // - invoice.create // - invoice.update // - invoice.payment_failed // - transfer.success, transfer.failed (if using Paystack transfers) default: Log::info('Paystack Webhook: Received unhandled event type: ' . $eventType); } return response()->json(['status' => 'success'], 200); } protected function handleSuccessfulCharge($data) { // Example: Find subscription by reference (if you store Paystack's transaction reference) // $subscription = Subscription::where('gateway_transaction_id', $data->reference)->first(); // if ($subscription && $subscription->status !== 'active') { // $subscription->status = 'active'; // // Potentially update ends_at based on the new payment // $subscription->save(); // Log::info("Paystack Webhook: Subscription ID {$subscription->id} marked active via charge.success for reference {$data->reference}."); // } Log::info("Paystack Webhook: Successful charge processed for reference: {$data->reference}", (array)$data); // If this charge is for a subscription renewal, you'd update the subscription's `ends_at` date. } } --- File: D:\projects\digitalvocano\Modules\PaystackGateway\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\PaystackGateway\Providers\PaystackGatewayServiceProvider.php --- registerConfig(); $this->loadMigrationsFrom(module_path($this->moduleName, 'Database/Migrations')); $this->registerViews(); // Uncomment and implement this $this->registerTranslations(); // Uncomment and implement if you have translations // 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')); // $this->loadRoutesFrom(module_path($this->moduleName, 'Routes/api.php')); } // In Modules\PaystackGateway\Providers\PaystackGatewayServiceProvider.php public function register(): void { $this->app->register(EventServiceProvider::class); // Register the module's EventServiceProvider $this->app->register(RouteServiceProvider::class); // Register the module's RouteServiceProvider $this->app->singleton(PaystackService::class, function ($app) { return new PaystackService(); }); } 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\PaystackGateway\Providers\RouteServiceProvider.php --- mapWebRoutes(); // $this->mapAdminRoutes(); // Admin routes are loaded by the main app's RouteServiceProvider by convention $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') ->namespace($this->moduleNamespace) ->group(module_path($this->name, 'routes/web.php')); } // 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/paystackgateway', // naming with 'admin.paystackgateway.', and namespacing to 'Modules\PaystackGateway\Http\Controllers\Admin'. // } /** * Define the "api" routes for the application. */ protected function mapApiRoutes(): void { // The main app's RouteServiceProvider applies 'api' middleware, // 'api/paystackgateway' prefix, 'api.paystackgateway.' name prefix, // and the base 'Modules\PaystackGateway\Http\Controllers' namespace. Route::namespace($this->moduleNamespace) // Assuming API controllers are directly under Http/Controllers ->group(module_path($this->name, 'routes/api.php')); } } --- File: D:\projects\digitalvocano\Modules\PaystackGateway\resources\views\admin\config.blade.php --- @extends('layouts.admin') @section('title', 'Paystack Gateway Settings') @section('header_title', 'Paystack Gateway Settings') @section('content')
@include('admin.partials.alerts') {{-- For success/error messages --}}
@csrf @method('PUT')
@php $id = 'paystack_enabled'; $name = 'paystack_enabled'; $label = 'Enable Paystack Gateway'; $value = '1'; $checked = old('paystack_enabled', $settings['paystack_enabled'] ?? '0') == '1'; $helpText = 'Toggle this to activate or deactivate the Paystack payment gateway.'; @endphp @if($helpText)

{{ $helpText }}

@endif @error('paystack_enabled')

{{ $message }}

@enderror
@error('paystack_mode')

{{ $message }}

@enderror
{{-- Spacer for grid --}}
@error('paystack_public_key')

{{ $message }}

@enderror

Enter a new secret to update it. Leave blank to keep the existing one.

@error('paystack_secret_key')

{{ $message }}

@enderror
@endsection --- File: D:\projects\digitalvocano\Modules\PaystackGateway\resources\views\components\layouts\master.blade.php --- PaystackGateway Module - {{ config('app.name', 'Laravel') }} {{-- Vite CSS --}} {{-- {{ module_vite('build-paystackgateway', 'resources/assets/sass/app.scss') }} --}} {{ $slot }} {{-- Vite JS --}} {{-- {{ module_vite('build-paystackgateway', 'resources/assets/js/app.js') }} --}} --- File: D:\projects\digitalvocano\Modules\PaystackGateway\resources\views\index.blade.php ---

Hello World

Module: {!! config('paystackgateway.name') !!}

--- File: D:\projects\digitalvocano\Modules\PaystackGateway\routes\admin.php --- name('settings.edit'); Route::put('settings', [PaystackConfigController::class, 'update'])->name('settings.update'); --- File: D:\projects\digitalvocano\Modules\PaystackGateway\routes\api.php --- prefix('v1')->group(function () { Route::apiResource('paystackgateways', PaystackGatewayController::class)->names('paystackgateway'); }); --- File: D:\projects\digitalvocano\Modules\PaystackGateway\routes\web.php --- group(function () { // Route::resource('paystackgateways', PaystackGatewayController::class)->names('paystackgateway'); // }); // Webhook - should generally be exempt from CSRF Route::post('webhooks/paystack', [PaystackWebhookController::class, 'handleWebhook'])->name('webhooks.paystack.handle'); Route::middleware(['web', 'auth']) // User must be authenticated ->prefix('subscribe/paystack') ->name('subscription.paystack.') ->group(function () { // The {subscriptionPlan:slug} will use route model binding Route::get('initialize/{subscriptionPlan:slug}', [PaystackSubscriptionController::class, 'initializePayment'])->name('initialize'); Route::get('callback', [PaystackSubscriptionController::class, 'handleCallback'])->name('callback'); // Paystack callback }); Route::middleware(['web', 'auth']) ->prefix('wallet/paystack') ->name('wallet.paystack.') ->group(function () { // The WalletController redirects to this route with amount in POST/GET Route::match(['get', 'post'], 'initialize-deposit', [PaystackSubscriptionController::class, 'initializeWalletDeposit'])->name('initializeDeposit'); Route::get('deposit-callback', [PaystackSubscriptionController::class, 'handleWalletDepositCallback'])->name('depositCallback'); }); --- File: D:\projects\digitalvocano\Modules\PaystackGateway\Services\PaystackService.php --- publicKey = setting('paystack_public_key'); $this->secretKey = setting('paystack_secret_key'); // Note: Paystack doesn't have a 'mode' in the API calls like PayPal SDK. // The live/test distinction is handled by using live or test keys. } protected function makeRequest(string $method, string $uri, array $data = []) { if (!$this->secretKey) { Log::error('Paystack secret key is not configured.'); return null; // Or throw an exception } $response = Http::withToken($this->secretKey) ->baseUrl($this->baseUrl) ->{$method}($uri, $data); if (!$response->successful()) { Log::error("Paystack API Error: {$uri}", [ 'status' => $response->status(), 'response' => $response->json() ?? $response->body() ]); // You might want to throw a custom exception here return null; } return $response->json(); } public function initializeTransaction(int $amountKobo, string $email, string $reference, string $callbackUrl, array $metadata = []) { $data = [ 'amount' => $amountKobo, 'email' => $email, 'reference' => $reference, 'callback_url' => $callbackUrl, 'metadata' => json_encode($metadata) // Paystack expects metadata as a JSON string ]; // You can add more parameters like 'channels', 'currency' (NGN is default) if needed return $this->makeRequest('post', 'transaction/initialize', $data); } public function verifyTransaction(string $reference) { return $this->makeRequest('get', "transaction/verify/{$reference}"); } public function createPlan(string $name, string $interval, int $amountKobo, string $description = null) { // Paystack intervals: hourly, daily, weekly, monthly, biannually, annually // Your system intervals: day, week, month, year. Need mapping. $paystackIntervalMap = [ 'day' => 'daily', 'week' => 'weekly', 'month' => 'monthly', 'year' => 'annually', ]; $paystackInterval = $paystackIntervalMap[strtolower($interval)] ?? 'monthly'; return $this->makeRequest('post', 'plan', [ 'name' => $name, 'interval' => $paystackInterval, 'amount' => $amountKobo, // Amount in Kobo 'description' => $description, ]); } /** * Initialize a subscription transaction with Paystack. * * @param User $user * @param SubscriptionPlan $localPlan * @param int|null $localSubscriptionId The ID of your local pending subscription record to include in metadata. * @return array|null An array containing 'authorization_url' and 'reference', or null on failure. * @throws \Exception */ public function initializeSubscriptionTransaction(User $user, SubscriptionPlan $localPlan, ?int $localSubscriptionId = null): ?array { if (!$this->secretKey) { Log::error('PaystackService: Secret key is not configured.'); throw new \Exception('Paystack payment gateway is not configured properly.'); } $paystackPlanCode = $localPlan->paystack_plan_code; if (empty($paystackPlanCode)) { // Plan doesn't exist on Paystack yet, create it. $amountKobo = (int) round($localPlan->price * 100); // Ensure price is converted to Kobo $planName = $localPlan->name . ' - ' . $localPlan->interval . ($localPlan->interval_count > 1 ? ' ' . $localPlan->interval_count : ''); $paystackPlan = $this->createPlan($planName, $localPlan->interval, $amountKobo, $localPlan->description); if ($paystackPlan && isset($paystackPlan['status']) && $paystackPlan['status'] === true && isset($paystackPlan['data']['plan_code'])) { $paystackPlanCode = $paystackPlan['data']['plan_code']; $localPlan->paystack_plan_code = $paystackPlanCode; $localPlan->save(); // Save the Paystack plan code to your local plan Log::info("PaystackService: Created Paystack plan '{$paystackPlanCode}' for local plan ID {$localPlan->id}"); } else { Log::error("PaystackService: Failed to create Paystack plan for local plan ID {$localPlan->id}", ['response' => $paystackPlan]); throw new \Exception('Failed to create payment plan on Paystack.'); } } // Now initialize the transaction with the Paystack plan code $reference = 'SUB__' . $user->id . '__' . $localPlan->id . '__' . time() . '__' . uniqid(); $callbackUrl = route('subscription.paystack.callback'); // Ensure this route exists in your PaystackGateway module $data = [ 'email' => $user->email, 'amount' => (int) round($localPlan->price * 100), // Amount in Kobo, for the first payment if not covered by plan 'plan' => $paystackPlanCode, // Key for subscription 'reference' => $reference, 'callback_url' => $callbackUrl, 'metadata' => json_encode([ 'user_id' => $user->id, 'local_plan_id' => $localPlan->id, 'subscription_id' => $localSubscriptionId, // Pass your local subscription ID 'custom_fields' => [ // Paystack's recommended structure for metadata ['display_name' => "User Name", "variable_name" => "user_name", "value" => $user->name], ['display_name' => "Plan Name", "variable_name" => "plan_name", "value" => $localPlan->name] ] ]) ]; $response = $this->makeRequest('post', 'transaction/initialize', $data); if ($response && isset($response['status']) && $response['status'] === true && isset($response['data']['authorization_url'], $response['data']['reference'])) { return [ 'authorization_url' => $response['data']['authorization_url'], 'reference' => $response['data']['reference'] // This is the reference Paystack will use ]; } return null; } }