VerbFilter::class, 'actions' => [ 'setup' => ['post'], 'delete-goal' => ['post'], 'simulate-stripe-webhook' => ['post'], 'simulate-paypal-webhook' => ['post'], 'reconcile-pending' => ['post'], 'create-animal-goal-inline' => ['post'], ], ]; return $behaviors; } protected function getAccessRules() { return [[ ContentContainerControllerAccess::RULE_USER_GROUP_ONLY => [ Space::USERGROUP_OWNER, Space::USERGROUP_ADMIN, ], ]]; } public function actionIndex($goalId = null) { $activeTab = $this->getRequestedTab(); $subNav = null; if (class_exists(RescueSettingsMenu::class)) { $subNav = RescueSettingsMenu::widget(['space' => $this->contentContainer]); } $providerForm = new ProviderSettingsForm([ 'contentContainer' => $this->contentContainer, ]); $schemaReady = $this->isSchemaReady(); if ($schemaReady) { $providerForm->loadValues(); } $goalForm = new DonationGoalForm([ 'contentContainer' => $this->contentContainer, ]); if ($schemaReady && $goalId !== null) { $goal = DonationGoal::findOne([ 'id' => (int)$goalId, 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, ]); if ($goal instanceof DonationGoal) { $goalForm->loadFromGoal($goal); $activeTab = 'goals'; } } elseif ($schemaReady && Yii::$app->request->post('DonationGoalForm') === null) { $prefillGoalType = trim((string)Yii::$app->request->get('goalType', '')); $prefillAnimalId = (int)Yii::$app->request->get('targetAnimalId', 0); if ($prefillGoalType === DonationGoal::TYPE_ANIMAL) { $goalForm->goal_type = DonationGoal::TYPE_ANIMAL; $activeTab = 'goals'; $animalOptions = $goalForm->getAnimalOptions(); if ($prefillAnimalId > 0 && isset($animalOptions[$prefillAnimalId])) { $goalForm->target_animal_id = $prefillAnimalId; if (trim((string)$goalForm->title) === '') { $goalForm->title = Yii::t('DonationsModule.base', '{animalName} Care Fund', [ 'animalName' => (string)$animalOptions[$prefillAnimalId], ]); } } } } if (Yii::$app->request->post('ProviderSettingsForm') !== null) { $activeTab = $this->sanitizeTab((string)Yii::$app->request->post('active_tab', 'payment-providers')); if (!$schemaReady) { $this->view->error(Yii::t('DonationsModule.base', 'Please run Donations setup first.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } if ($providerForm->load(Yii::$app->request->post()) && $providerForm->save()) { $this->view->success(Yii::t('DonationsModule.base', 'Provider settings saved.')); $redirectTab = trim((string)Yii::$app->request->post('active_tab', 'payment-providers')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => $this->sanitizeTab($redirectTab), ])); } } if (Yii::$app->request->post('DonationGoalForm') !== null) { if (!$schemaReady) { $this->view->error(Yii::t('DonationsModule.base', 'Please run Donations setup first.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } if ($goalForm->load(Yii::$app->request->post())) { $goalForm->imageFile = UploadedFile::getInstance($goalForm, 'imageFile'); } if ($goalForm->save()) { $this->view->success(Yii::t('DonationsModule.base', 'Donation goal saved.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'goals', ])); } $activeTab = 'goals'; } $goals = []; $transactions = []; $subscriptions = []; $webhookEvents = []; $animalGalleryImageMap = []; if ($schemaReady) { $goals = DonationGoal::find() ->where(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]) ->orderBy(['is_active' => SORT_DESC, 'id' => SORT_DESC]) ->all(); $transactions = DonationTransaction::find() ->where(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]) ->orderBy(['id' => SORT_DESC]) ->limit(150) ->all(); $subscriptions = DonationSubscription::find() ->where(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]) ->orderBy(['id' => SORT_DESC]) ->limit(150) ->all(); $webhookEvents = DonationWebhookEvent::find() ->orderBy(['id' => SORT_DESC]) ->limit(150) ->all(); $animalGalleryImageMap = $this->resolveAnimalGalleryImageMap($goalForm, $goalForm->getAnimalOptions()); } return $this->render('index', [ 'subNav' => $subNav, 'providerForm' => $providerForm, 'goalForm' => $goalForm, 'goals' => $goals, 'animalOptions' => $schemaReady ? $goalForm->getAnimalOptions() : [], 'animalGalleryImageMap' => $animalGalleryImageMap, 'transactions' => $transactions, 'subscriptions' => $subscriptions, 'webhookEvents' => $webhookEvents, 'activeTab' => $activeTab, 'schemaReady' => $schemaReady, ]); } public function actionSetup() { if (!Yii::$app->request->isPost) { throw new BadRequestHttpException('Invalid request method.'); } if (!$this->contentContainer instanceof Space) { $this->view->error(Yii::t('DonationsModule.base', 'Setup can only be run inside a space.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } try { $result = ModuleSetupService::runForSpace($this->contentContainer); $appliedCount = count($result['applied'] ?? []); if ($appliedCount > 0) { $this->view->success(Yii::t('DonationsModule.base', 'Setup completed. Applied {count} migration(s).', [ 'count' => $appliedCount, ])); } else { $this->view->success(Yii::t('DonationsModule.base', 'Setup completed. No pending migrations were found.')); } } catch (\Throwable $e) { Yii::error($e, 'donations.setup'); $this->view->error(Yii::t('DonationsModule.base', 'Setup failed. Please check logs and try again.')); } return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } public function actionDeleteGoal($id) { if (!$this->isSchemaReady()) { $this->view->error(Yii::t('DonationsModule.base', 'Please run Donations setup first.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $goal = DonationGoal::findOne([ 'id' => (int)$id, 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, ]); if (!$goal instanceof DonationGoal) { throw new NotFoundHttpException('Donation goal not found.'); } $goal->delete(); $this->view->success(Yii::t('DonationsModule.base', 'Donation goal deleted.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'goals', ])); } public function actionCreateAnimalGoalInline($animalId) { if (!$this->isSchemaReady()) { $this->view->error(Yii::t('DonationsModule.base', 'Please run Donations setup first.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $goalForm = new DonationGoalForm([ 'contentContainer' => $this->contentContainer, 'goal_type' => DonationGoal::TYPE_ANIMAL, 'target_animal_id' => (int)$animalId, ]); $postGoalData = Yii::$app->request->post('DonationGoalForm', []); $postedGoalId = (int)($postGoalData['id'] ?? 0); if ($postedGoalId > 0) { $existingGoal = DonationGoal::findOne([ 'id' => $postedGoalId, 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, 'goal_type' => DonationGoal::TYPE_ANIMAL, 'target_animal_id' => (int)$animalId, ]); if ($existingGoal instanceof DonationGoal) { $goalForm->loadFromGoal($existingGoal); } } if ($goalForm->load(Yii::$app->request->post())) { $goalForm->goal_type = DonationGoal::TYPE_ANIMAL; $goalForm->target_animal_id = (int)$animalId; $goalForm->imageFile = UploadedFile::getInstance($goalForm, 'imageFile'); if ($goalForm->save()) { $this->view->success(Yii::t('DonationsModule.base', 'Donation goal saved.')); } else { $errors = $goalForm->getFirstErrors(); $message = !empty($errors) ? implode(' ', array_values($errors)) : Yii::t('DonationsModule.base', 'Unable to save donation goal.'); $this->view->error($message); } } else { $this->view->error(Yii::t('DonationsModule.base', 'Invalid donation goal request.')); } return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index')); } public function actionHistory() { return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'donation-history', ])); } public function actionSimulateStripeWebhook() { if (!$this->isSchemaReady()) { $this->view->error(Yii::t('DonationsModule.base', 'Please run Donations setup first.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $config = DonationProviderConfig::findOne(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]); if (!$config instanceof DonationProviderConfig || (int)$config->stripe_enabled !== 1) { $this->view->error(Yii::t('DonationsModule.base', 'Stripe is not enabled for this space.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $secret = (new ProviderCredentialResolver($this->contentContainer))->resolveStripeWebhookSecret($config); $secret = trim((string)$secret); if ($secret === '') { $this->view->error(Yii::t('DonationsModule.base', 'Stripe webhook secret is required to run simulation.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $transaction = $this->resolveSimulationTransaction('stripe'); if (!$transaction instanceof DonationTransaction) { $this->view->error(Yii::t('DonationsModule.base', 'No Stripe transaction found to simulate. Create a Stripe donation intent first.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $sessionId = $transaction->provider_checkout_id ?: ('cs_test_' . Yii::$app->security->generateRandomString(16)); $paymentIntentId = $transaction->provider_payment_id ?: ('pi_test_' . Yii::$app->security->generateRandomString(16)); $event = [ 'id' => 'evt_test_' . Yii::$app->security->generateRandomString(16), 'type' => 'checkout.session.completed', 'data' => [ 'object' => [ 'id' => $sessionId, 'payment_intent' => $paymentIntentId, 'subscription' => $transaction->mode === 'recurring' ? ($transaction->provider_subscription_id ?: ('sub_test_' . Yii::$app->security->generateRandomString(14))) : null, 'customer' => $transaction->provider_customer_id ?: ('cus_test_' . Yii::$app->security->generateRandomString(12)), 'metadata' => [ 'transaction_id' => (string)$transaction->id, 'goal_id' => (string)$transaction->goal_id, 'contentcontainer_id' => (string)$transaction->contentcontainer_id, ], ], ], ]; $payload = json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $timestamp = (string)time(); $signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret); $signatureHeader = 't=' . $timestamp . ',v1=' . $signature; $service = new StripeWebhookService(); $result = $service->process($payload, $signatureHeader, $config); if (!($result['ok'] ?? false)) { $this->view->error(Yii::t('DonationsModule.base', 'Stripe simulation failed: {message}', [ 'message' => (string)($result['message'] ?? 'Unknown error'), ])); } else { $this->view->success(Yii::t('DonationsModule.base', 'Stripe webhook simulation processed successfully.')); } return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } public function actionSimulatePaypalWebhook() { if (!$this->isSchemaReady()) { $this->view->error(Yii::t('DonationsModule.base', 'Please run Donations setup first.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $config = DonationProviderConfig::findOne(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]); if (!$config instanceof DonationProviderConfig || (int)$config->paypal_enabled !== 1) { $this->view->error(Yii::t('DonationsModule.base', 'PayPal is not enabled for this space.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $transaction = $this->resolveSimulationTransaction('paypal'); if (!$transaction instanceof DonationTransaction) { $this->view->error(Yii::t('DonationsModule.base', 'No PayPal transaction found to simulate. Create a PayPal donation intent first.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $orderId = $transaction->provider_checkout_id ?: ('ORDER-' . strtoupper(Yii::$app->security->generateRandomString(12))); $captureId = $transaction->provider_payment_id ?: ('CAPTURE-' . strtoupper(Yii::$app->security->generateRandomString(12))); $event = [ 'id' => 'WH-TEST-' . strtoupper(Yii::$app->security->generateRandomString(12)), 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', 'resource' => [ 'id' => $captureId, 'custom_id' => (string)$transaction->id, 'supplementary_data' => [ 'related_ids' => [ 'order_id' => $orderId, ], ], ], ]; $service = new PayPalWebhookService(); $result = $service->processTestEvent($event); if (!($result['ok'] ?? false)) { $this->view->error(Yii::t('DonationsModule.base', 'PayPal simulation failed: {message}', [ 'message' => (string)($result['message'] ?? 'Unknown error'), ])); } else { $this->view->success(Yii::t('DonationsModule.base', 'PayPal webhook simulation processed successfully.')); } return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } public function actionReconcilePending() { if (!$this->isSchemaReady()) { $this->view->error(Yii::t('DonationsModule.base', 'Please run Donations setup first.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $config = DonationProviderConfig::findOne(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]); if (!$config instanceof DonationProviderConfig) { $this->view->error(Yii::t('DonationsModule.base', 'Provider settings are required before reconciliation can run.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $pending = DonationTransaction::find() ->where([ 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, 'status' => DonationTransaction::STATUS_PENDING, ]) ->orderBy(['id' => SORT_ASC]) ->limit(150) ->all(); if (empty($pending)) { $this->view->info(Yii::t('DonationsModule.base', 'No pending transactions found for reconciliation.')); return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } $settlement = new DonationSettlementService(); $gateway = new PaymentGatewayService(); $reconciled = 0; $skipped = 0; $failed = 0; foreach ($pending as $transaction) { if ($transaction->provider === 'paypal' && $transaction->mode === 'one_time' && (int)$config->paypal_enabled === 1) { $captureResult = $gateway->capturePayPalOrder($transaction, $config); if (!($captureResult['success'] ?? false)) { $failed++; $metadata = json_decode((string)$transaction->metadata_json, true); if (!is_array($metadata)) { $metadata = []; } $metadata['paypal_reconcile_capture'] = [ 'success' => false, 'checked_at' => date('c'), 'status' => (int)($captureResult['status'] ?? 0), 'error' => (string)($captureResult['error'] ?? 'Capture failed.'), ]; $transaction->metadata_json = json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $transaction->save(false); continue; } $transaction->provider_payment_id = (string)($captureResult['payment_id'] ?? $transaction->provider_payment_id); $settlement->markSucceededAndApply($transaction, [ 'paypal_reconciled_at' => date('c'), 'paypal_order_already_captured' => !empty($captureResult['already_captured']) ? 1 : 0, ]); $reconciled++; continue; } $skipped++; } if ($reconciled > 0) { $this->view->success(Yii::t('DonationsModule.base', 'Reconciliation finished. Reconciled {reconciled} transaction(s), skipped {skipped}, failed {failed}.', [ 'reconciled' => $reconciled, 'skipped' => $skipped, 'failed' => $failed, ])); } else { $this->view->info(Yii::t('DonationsModule.base', 'Reconciliation finished. No transactions were reconciled. Skipped {skipped}, failed {failed}.', [ 'skipped' => $skipped, 'failed' => $failed, ])); } return $this->redirect($this->contentContainer->createUrl('/donations/settings', [ 'tab' => 'advanced', ])); } private function resolveSimulationTransaction(string $provider): ?DonationTransaction { $transactionId = (int)Yii::$app->request->post('transaction_id', 0); if ($transactionId > 0) { $tx = DonationTransaction::findOne([ 'id' => $transactionId, 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, 'provider' => $provider, ]); if ($tx instanceof DonationTransaction) { return $tx; } } $tx = DonationTransaction::find() ->where([ 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, 'provider' => $provider, 'status' => DonationTransaction::STATUS_PENDING, ]) ->orderBy(['id' => SORT_DESC]) ->one(); if ($tx instanceof DonationTransaction) { return $tx; } return DonationTransaction::find() ->where([ 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, 'provider' => $provider, ]) ->orderBy(['id' => SORT_DESC]) ->one(); } private function getRequestedTab(): string { return $this->sanitizeTab((string)Yii::$app->request->get('tab', 'general')); } private function sanitizeTab(string $tab): string { $allowed = [ 'general', 'goals', 'payment-providers', 'donation-history', 'advanced', ]; return in_array($tab, $allowed, true) ? $tab : 'general'; } private function resolveAnimalGalleryImageMap(DonationGoalForm $goalForm, array $animalOptions): array { $map = []; foreach (array_keys($animalOptions) as $animalId) { $options = $goalForm->getGalleryImageOptionsForAnimal((int)$animalId); if (empty($options)) { continue; } $map[(int)$animalId] = array_values(array_keys($options)); } return $map; } private function isSchemaReady(): bool { return Yii::$app->db->schema->getTableSchema(DonationGoal::tableName(), true) !== null && Yii::$app->db->schema->getTableSchema(DonationProviderConfig::tableName(), true) !== null; } }