diff --git a/Events.php b/Events.php new file mode 100644 index 0000000..555b249 --- /dev/null +++ b/Events.php @@ -0,0 +1,435 @@ +sender->space ?? null; + if ($space === null || !$space->moduleManager->isEnabled('donations')) { + return; + } + + $event->sender->addItem([ + 'label' => Yii::t('DonationsModule.base', 'My Donations'), + 'group' => 'modules', + 'url' => $space->createUrl('/donations/donations/index'), + 'icon' => '', + 'sortOrder' => 120, + 'isActive' => ( + Yii::$app->controller + && Yii::$app->controller->module + && Yii::$app->controller->module->id === 'donations' + && Yii::$app->controller->id === 'donations' + ), + ]); + } + + public static function onRescueSettingsMenuInit($event): void + { + $space = $event->sender->space ?? null; + if ($space === null || !$space->moduleManager->isEnabled('donations')) { + return; + } + + $event->sender->addItem([ + 'label' => Yii::t('DonationsModule.base', 'Donations'), + 'url' => $space->createUrl('/donations/settings'), + 'sortOrder' => 400, + 'isActive' => ( + Yii::$app->controller + && Yii::$app->controller->module + && Yii::$app->controller->module->id === 'donations' + && Yii::$app->controller->id === 'settings' + ), + ]); + } + + public static function onSpaceAdminMenuInitFallback($event): void + { + $space = $event->sender->space ?? null; + if ($space === null || !$space->moduleManager->isEnabled('donations')) { + return; + } + + if ($space->moduleManager->isEnabled('rescue_foundation') || !$space->isAdmin()) { + return; + } + + $event->sender->addItem([ + 'label' => Yii::t('DonationsModule.base', 'Donations'), + 'group' => 'admin', + 'url' => $space->createUrl('/donations/settings'), + 'icon' => '', + 'sortOrder' => 620, + 'isActive' => ( + Yii::$app->controller + && Yii::$app->controller->module + && Yii::$app->controller->module->id === 'donations' + && Yii::$app->controller->id === 'settings' + ), + ]); + } + + public static function onDonationTransactionSucceeded($event): void + { + if (!$event instanceof DonationSettlementEvent) { + return; + } + + $payload = is_array($event->payload) ? $event->payload : []; + + $animalIntegration = new DonationAnimalIntegrationService(); + $payload = $animalIntegration->applySucceededIntegration($event->transaction, $event->goal, $payload); + + $notificationIntegration = new DonationNotificationService(); + $payload = $notificationIntegration->applySucceededNotifications($event->transaction, $event->goal, $payload); + + $event->payload = $payload; + } + + public static function onDonationTransactionRefunded($event): void + { + if (!$event instanceof DonationSettlementEvent) { + return; + } + + $payload = is_array($event->payload) ? $event->payload : []; + + $animalIntegration = new DonationAnimalIntegrationService(); + $payload = $animalIntegration->applyRefundedIntegration($event->transaction, $event->goal, $payload); + + $notificationIntegration = new DonationNotificationService(); + $payload = $notificationIntegration->applyRefundedNotifications($event->transaction, $event->goal, $payload); + + $event->payload = $payload; + } + + public static function onAnimalTileOverlayRender($event): void + { + $hookClass = 'humhub\\modules\\animal_management\\events\\AnimalTileRenderEvent'; + if (!class_exists($hookClass) || !$event instanceof $hookClass) { + return; + } + + $space = $event->contentContainer ?? null; + if (!$space instanceof Space || !$space->moduleManager->isEnabled('donations')) { + return; + } + + if (Yii::$app->db->schema->getTableSchema(DonationGoal::tableName(), true) === null) { + return; + } + + $goal = $event->existingDonationGoal ?? null; + if (!$goal instanceof DonationGoal) { + $goal = DonationGoal::find() + ->where([ + 'contentcontainer_id' => (int)$space->contentcontainer_id, + 'goal_type' => DonationGoal::TYPE_ANIMAL, + 'target_animal_id' => (int)($event->animal->id ?? 0), + ]) + ->orderBy(['is_active' => SORT_DESC, 'id' => SORT_DESC]) + ->one(); + } + + if (!$goal instanceof DonationGoal) { + return; + } + + $target = (float)$goal->target_amount; + $current = max(0.0, (float)$goal->current_amount); + $isFunded = $target > 0 && $current >= $target; + + $percent = $target > 0 ? min(100.0, round(($current / $target) * 100, 1)) : 0.0; + $percentLabel = rtrim(rtrim(number_format($percent, 1, '.', ''), '0'), '.') . '%'; + $targetLabel = '$' . number_format(round(max(0.0, $target)), 0); + $currentLabel = '$' . number_format(round($current), 0); + $statusColor = $isFunded ? '#4ce083' : '#ff6666'; + + $goalToggleInputId = 'donation-goal-toggle-' . (int)($event->animal->id ?? 0); + $quickDonateToggleInputId = 'donation-goal-quick-toggle-' . (int)($event->animal->id ?? 0); + $goalCardId = 'donation-goal-card-' . (int)($event->animal->id ?? 0); + $donorPanelId = 'donation-goal-donors-panel-' . (int)($event->animal->id ?? 0); + $quickDonatePanelId = 'donation-goal-quick-panel-' . (int)($event->animal->id ?? 0); + $donorPagerWrapId = 'donation-goal-donors-pager-' . (int)($event->animal->id ?? 0); + $donorPagerName = 'donation-goal-donors-page-' . (int)($event->animal->id ?? 0); + + $donorRows = []; + if (Yii::$app->db->schema->getTableSchema(DonationTransaction::tableName(), true) !== null) { + $transactions = DonationTransaction::find() + ->where([ + 'contentcontainer_id' => (int)$space->contentcontainer_id, + 'goal_id' => (int)$goal->id, + 'status' => DonationTransaction::STATUS_SUCCEEDED, + ]) + ->orderBy(['id' => SORT_DESC]) + ->limit(200) + ->all(); + + $totalsByUser = []; + $anonymousTotal = 0.0; + foreach ($transactions as $transaction) { + $amount = max(0.0, (float)$transaction->amount); + if ($amount <= 0) { + continue; + } + + if ((int)$transaction->is_anonymous === 1 || (int)$transaction->donor_user_id <= 0) { + $anonymousTotal += $amount; + continue; + } + + $userId = (int)$transaction->donor_user_id; + $totalsByUser[$userId] = ($totalsByUser[$userId] ?? 0.0) + $amount; + } + + if (!empty($totalsByUser)) { + arsort($totalsByUser); + $users = User::find() + ->where(['id' => array_keys($totalsByUser)]) + ->indexBy('id') + ->all(); + + foreach ($totalsByUser as $userId => $totalAmount) { + $user = $users[$userId] ?? null; + if (!$user instanceof User) { + continue; + } + + $avatarUrl = (string)$user->getProfileImage()->getUrl(); + $profileUrl = (string)$user->getUrl(); + $displayName = (string)$user->getDisplayName(); + $amountText = '$' . number_format(round($totalAmount), 0); + + $donorRows[] = + '' + . '' + . '' . Html::encode($displayName) . '' + . '' . Html::encode($amountText) . '' + . ''; + } + } + + if ($anonymousTotal > 0) { + $donorRows[] = + '
' + . '' + . '' + . '' + . '' . Html::encode(Yii::t('DonationsModule.base', 'Anonymous')) . '' + . '$' . Html::encode(number_format(round($anonymousTotal), 0)) . '' + . '
'; + } + } + + $donorPageRows = empty($donorRows) + ? ['
' . Html::encode(Yii::t('DonationsModule.base', 'No donations yet.')) . '
'] + : $donorRows; + + $donorPageSize = 25; + $donorPages = array_chunk($donorPageRows, $donorPageSize); + $donorTotalPages = max(1, count($donorPages)); + + $donorPagesHtml = ''; + foreach ($donorPages as $pageIndex => $pageRows) { + $pageNumber = $pageIndex + 1; + $donorPagesHtml .= '
' + . implode('', $pageRows) + . '
'; + } + $donorPagerCss = '#'. Html::encode($donorPagerWrapId) . ' [data-donor-page],#' . Html::encode($donorPagerWrapId) . ' [data-donor-controls-page]{display:none;}'; + $donorPagerRadioInputsHtml = ''; + $donorPagerTopControlsHtml = ''; + $donorPagerBottomControlsHtml = ''; + + foreach ($donorPages as $pageIndex => $pageRows) { + $pageNumber = $pageIndex + 1; + $radioId = $donorPagerName . '-' . (int)$pageNumber; + $prevRadioId = $donorPagerName . '-' . (int)max(1, $pageNumber - 1); + $nextRadioId = $donorPagerName . '-' . (int)min($donorTotalPages, $pageNumber + 1); + + $donorPagerRadioInputsHtml .= ''; + + $prevControl = $pageNumber > 1 + ? '' + : '' . Html::encode(Yii::t('DonationsModule.base', 'Previous')) . ''; + + $nextControl = $pageNumber < $donorTotalPages + ? '' + : '' . Html::encode(Yii::t('DonationsModule.base', 'Next')) . ''; + + $pageLabel = Html::encode('Page ' . $pageNumber . ' / ' . $donorTotalPages); + $controlsHtml = '
' + . $prevControl + . '' . $pageLabel . '' + . $nextControl + . '
'; + + $donorPagerTopControlsHtml .= $controlsHtml; + $donorPagerBottomControlsHtml .= $controlsHtml; + + $donorPagerCss .= '#' . Html::encode($radioId) . ':checked ~ [data-donor-top-controls] [data-donor-controls-page="' . (int)$pageNumber . '"]{display:flex;}'; + $donorPagerCss .= '#' . Html::encode($radioId) . ':checked ~ [data-donor-pages] [data-donor-page="' . (int)$pageNumber . '"]{display:block;}'; + $donorPagerCss .= '#' . Html::encode($radioId) . ':checked ~ [data-donor-bottom-controls] [data-donor-controls-page="' . (int)$pageNumber . '"]{display:flex;}'; + } + + $goalToggleCss = ''; + + $quickDonateHeaderTemplate = (string)Yii::$app->getModule('donations')->settings->contentContainer($space) + ->get('animal_donation_form_header', Yii::t('DonationsModule.base', 'Your Donation directly supports [animal-name]')); + $quickDonateHeaderTemplate = trim($quickDonateHeaderTemplate); + if ($quickDonateHeaderTemplate === '') { + $quickDonateHeaderTemplate = Yii::t('DonationsModule.base', 'Your Donation directly supports [animal-name]'); + } + $quickDonateHeaderText = str_replace('[animal-name]', (string)$event->animal->getDisplayName(), $quickDonateHeaderTemplate); + + $quickDonateActionUrl = $space->createUrl('/donations/donations/donate'); + $quickDonatePanelHtml = ''; + + $donorPanelHtml = $goalToggleCss + . '' + . '' + . ''; + + $goalLabelHtml = ''; + + $percentColor = $percent >= 100.0 ? '#4ce083' : '#ffffff'; + + $currentAmountHtml = '' . Html::encode($currentLabel) . ''; + + $targetAmountNumberLabel = ltrim($targetLabel, '$'); + if ($targetAmountNumberLabel === '') { + $targetAmountNumberLabel = $targetLabel; + } + + $targetAmountInnerHtml = '' + . '$' + . '' . Html::encode($targetAmountNumberLabel) . '' + . ''; + + $donateCtaHtml = '' + . Html::encode(Yii::t('DonationsModule.base', 'Donate')) + . ''; + + $targetAmountHtml = '' . $targetAmountInnerHtml . ''; + if (!empty($event->showDonationSettingsButton) && !empty($event->donationToggleInputId)) { + $toggleInputId = (string)$event->donationToggleInputId; + $inlineFormId = (string)($event->donationInlineFormId ?? ''); + $targetOnClick = ''; + if ($inlineFormId !== '') { + $targetOnClick = ' onclick="var c=document.getElementById(\'' . Html::encode($toggleInputId) . '\');var f=document.getElementById(\'' . Html::encode($inlineFormId) . '\');if(c&&f){window.setTimeout(function(){f.style.display=c.checked?\'block\':\'none\';},0);}"'; + } + + $targetAmountHtml = ''; + } + + $event->addHtml( + '
' + . '
' + . '' + . '
' + . $goalLabelHtml + . $targetAmountHtml + . '
' + . '
' + . '' + . '
' + . '
' + . $currentAmountHtml + . $donateCtaHtml + . '' . Html::encode($percentLabel) . '' + . '
' + . '
' + . $donorPanelHtml + . $quickDonatePanelHtml + . '
' + ); + } + + public static function onAnimalTileSizeResolve($event): void + { + $hookClass = 'humhub\\modules\\animal_management\\events\\AnimalTileSizeEvent'; + if (!class_exists($hookClass) || !$event instanceof $hookClass) { + return; + } + + $space = $event->contentContainer ?? null; + if (!$space instanceof Space || !$space->moduleManager->isEnabled('donations')) { + return; + } + + $settings = Yii::$app->getModule('donations')->settings->contentContainer($space); + $extraHeight = max(0, (int)$settings->get('animal_tile_extra_height_px', 0)); + if ($extraHeight <= 0) { + return; + } + + $event->addAdditionalHeightPx($extraHeight); + } +} diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..87b1279 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,63 @@ +# Donations Installation Guide + +This guide installs the `donations` module in a reusable way for any HumHub instance. + +## 1. Requirements + +- HumHub `1.14+` +- Module directory access on the target instance +- A configured payment provider setup (Stripe and/or PayPal) after installation +- Optional but recommended: `rescue_foundation` module + +## 2. Clone into HumHub Modules Directory + +The folder name must be exactly `donations`. + +```bash +git clone https://gitea.kelinreij.duckdns.org/humhub-modules/donations.git \ + /var/www/localhost/htdocs/protected/modules/donations +``` + +If the folder already exists: + +```bash +cd /var/www/localhost/htdocs/protected/modules/donations +git pull +``` + +## 3. Enable the Module + +In HumHub UI: + +1. Go to `Administration` -> `Modules`. +2. Enable `Donations`. +3. Enable it per space where donations should be active. + +## 4. Run Migrations + +From the HumHub app host/container: + +```bash +php /var/www/localhost/htdocs/protected/yii migrate/up \ + --include-module-migrations=1 --interactive=0 +``` + +## 5. Post-Install Setup + +1. Open `Space` -> `Settings` -> `Donations`. +2. Run module setup if prompted. +3. Configure provider credentials and webhook values. +4. Create at least one donation goal. + +## 6. Verify + +1. Open `Space` -> `Donations`. +2. Submit a test donation intent. +3. Confirm transaction records update as expected. + +## Docker Example + +```bash +docker exec humhub php /var/www/localhost/htdocs/protected/yii migrate/up \ + --include-module-migrations=1 --interactive=0 +``` diff --git a/Module.php b/Module.php new file mode 100644 index 0000000..94cb0f5 --- /dev/null +++ b/Module.php @@ -0,0 +1,38 @@ +moduleManager->isEnabled('rescue_foundation')) { + return $container->createUrl('/rescue_foundation/settings'); + } + + return $container->createUrl('/donations/settings'); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..350fec7 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Donations Module (MVP Scaffold) + +This module adds donation goal management and donation intent capture for HumHub spaces. + +## Current MVP Features + +- Space menu entry: **Donations** +- Space settings page with: + - One-click **Run Donations Setup** + - PayPal and Stripe provider settings (including recurring toggles) + - Donation goal creation/edit/delete +- Public donations page in each enabled space +- Stripe checkout session creation for one-time and recurring donations +- PayPal one-time order creation and approve redirect +- PayPal recurring subscription creation and approve redirect +- Stripe webhook processing for checkout completion and transaction state updates +- PayPal webhook signature verification and event processing for capture/subscription updates +- Donation intent creation and provider checkout metadata storage +- Admin history page for transactions, subscriptions, and recent webhook events +- Initial database schema for goals, transactions, subscriptions, webhook events, provider config, and blocks + +## Install / Update in Container + +```bash +docker cp /tmp/humhub_rescue_modules/donations humhub:/var/www/localhost/htdocs/protected/modules/ +``` + +## Apply Migrations + +```bash +docker exec humhub php /var/www/localhost/htdocs/protected/yii migrate/up --migrationPath=/var/www/localhost/htdocs/protected/modules/donations/migrations --interactive=0 +``` + +## Test Flow + +1. Enable module in a Space: `Space > Manage > Modules > Donations`. +2. Open `Space > Settings > Donations`. +3. Click **Run Donations Setup**. +4. Enable at least one provider (PayPal or Stripe) and save. +5. Create one or more donation goals. +6. Configure your Stripe/PayPal credentials in settings. +7. For Stripe, set webhook endpoint from Donations Settings (space-scoped URL), for example: + - `https:///s//donations/donations/stripe-webhook` +8. Open `Space > Donations` and submit a donation. +9. Confirm rows are created/updated in `rescue_donation_transaction`. +10. Use **Webhook Simulation Tools** in Donations Settings to validate Stripe/PayPal webhook processing locally. + +## Next Implementation Steps + +- Add stronger webhook secret resolution per space/site +- Add admin reporting and transaction history UI diff --git a/Readme.md b/Readme.md deleted file mode 100644 index a50faef..0000000 --- a/Readme.md +++ /dev/null @@ -1,4 +0,0 @@ -# Donations module for HumHub - -Enables PayPal & Stripe Payments -Integrates into animal-management module diff --git a/config.php b/config.php new file mode 100644 index 0000000..8bbd4ea --- /dev/null +++ b/config.php @@ -0,0 +1,20 @@ + 'donations', + 'class' => 'humhub\\modules\\donations\\Module', + 'namespace' => 'humhub\\modules\\donations', + 'events' => [ + [Menu::class, Menu::EVENT_INIT, [Events::class, 'onSpaceMenuInit']], + ['humhub\\modules\\rescue_foundation\\widgets\\RescueSettingsMenu', 'rescueSettingsMenuInit', [Events::class, 'onRescueSettingsMenuInit']], + [HeaderControlsMenu::class, HeaderControlsMenu::EVENT_INIT, [Events::class, 'onSpaceAdminMenuInitFallback']], + ['humhub\\modules\\donations\\services\\DonationSettlementService', 'afterSucceeded', [Events::class, 'onDonationTransactionSucceeded']], + ['humhub\\modules\\donations\\services\\DonationSettlementService', 'afterRefunded', [Events::class, 'onDonationTransactionRefunded']], + ['humhub\\modules\\animal_management\\events\\AnimalTileRenderEvent', 'renderOverlay', [Events::class, 'onAnimalTileOverlayRender']], + ['humhub\\modules\\animal_management\\events\\AnimalTileSizeEvent', 'resolveSize', [Events::class, 'onAnimalTileSizeResolve']], + ], +]; diff --git a/controllers/DonationsController.php b/controllers/DonationsController.php new file mode 100644 index 0000000..038a1e7 --- /dev/null +++ b/controllers/DonationsController.php @@ -0,0 +1,797 @@ + VerbFilter::class, + 'actions' => [ + 'donate' => ['post'], + 'stripe-webhook' => ['post'], + 'paypal-webhook' => ['post'], + ], + ]; + + return $behaviors; + } + + protected function getAccessRules() + { + return [[ + ContentContainerControllerAccess::RULE_USER_GROUP_ONLY => [ + Space::USERGROUP_OWNER, + Space::USERGROUP_ADMIN, + Space::USERGROUP_MODERATOR, + Space::USERGROUP_USER, + Space::USERGROUP_GUEST, + ], + ]]; + } + + public function beforeAction($action) + { + if (in_array($action->id, ['stripe-webhook', 'paypal-webhook'], true)) { + $this->enableCsrfValidation = false; + $this->detachBehavior('containerControllerBehavior'); + } + + return parent::beforeAction($action); + } + + public function actionIndex() + { + $schemaReady = $this->isSchemaReady(); + + $goals = []; + $providerConfig = null; + + if ($schemaReady) { + $goals = DonationGoal::find() + ->where([ + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + 'is_active' => 1, + ]) + ->orderBy(['id' => SORT_DESC]) + ->all(); + + $providerConfig = DonationProviderConfig::findOne([ + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ]); + } + + $dashboardData = $this->buildDonationDashboardData($schemaReady, $goals); + $canDonate = $this->canDonateInSpace(); + + return $this->render('index', [ + 'goals' => $goals, + 'providerConfig' => $providerConfig, + 'canDonate' => $canDonate, + 'providerOptions' => $this->getEnabledProviderOptions($providerConfig), + 'recurringOptions' => $this->getRecurringProviderKeys($providerConfig), + 'space' => $this->contentContainer, + 'schemaReady' => $schemaReady, + 'dashboardData' => $dashboardData, + ]); + } + + public function actionDonate() + { + if (!$this->isSchemaReady()) { + $this->view->error(Yii::t('DonationsModule.base', 'Donations setup has not been run yet.')); + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + if (!$this->canDonateInSpace()) { + throw new ForbiddenHttpException('You are not allowed to donate in this space.'); + } + + $goalId = (int)Yii::$app->request->post('goal_id'); + $goal = DonationGoal::findOne([ + 'id' => $goalId, + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + 'is_active' => 1, + ]); + + if (!$goal instanceof DonationGoal) { + $this->view->error(Yii::t('DonationsModule.base', 'Donation goal not found.')); + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + $providerConfig = DonationProviderConfig::findOne([ + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ]); + + $provider = strtolower(trim((string)Yii::$app->request->post('provider', ''))); + $mode = strtolower(trim((string)Yii::$app->request->post('mode', 'one_time'))); + $amount = (float)Yii::$app->request->post('amount', 0); + $isAnonymous = (int)Yii::$app->request->post('is_anonymous', 0) === 1; + + $enabledProviderOptions = $this->getEnabledProviderOptions($providerConfig); + if (!isset($enabledProviderOptions[$provider])) { + $this->view->error(Yii::t('DonationsModule.base', 'Selected provider is not enabled.')); + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + if (!in_array($mode, ['one_time', 'recurring'], true)) { + $this->view->error(Yii::t('DonationsModule.base', 'Invalid donation mode.')); + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + $recurringProviders = $this->getRecurringProviderKeys($providerConfig); + if ($mode === 'recurring' && !in_array($provider, $recurringProviders, true)) { + $this->view->error(Yii::t('DonationsModule.base', 'Selected provider does not have recurring enabled.')); + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + if ($amount <= 0) { + $this->view->error(Yii::t('DonationsModule.base', 'Please enter a valid donation amount.')); + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + $transaction = new DonationTransaction(); + $transaction->contentcontainer_id = $this->contentContainer->contentcontainer_id; + $transaction->donor_user_id = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id; + $transaction->provider = $provider; + $transaction->mode = $mode; + $transaction->status = DonationTransaction::STATUS_PENDING; + $transaction->amount = $amount; + $transaction->currency = strtoupper((string)($providerConfig->default_currency ?? $goal->currency ?? 'USD')); + $transaction->is_anonymous = $isAnonymous ? 1 : 0; + $transaction->goal_id = (int)$goal->id; + $transaction->goal_type = (string)$goal->goal_type; + $transaction->target_animal_id = $goal->target_animal_id !== null ? (int)$goal->target_animal_id : null; + $transaction->metadata_json = json_encode([ + 'source' => 'mvp_intent_stub', + 'goal_title' => $goal->title, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if (!$transaction->save()) { + $this->view->error(Yii::t('DonationsModule.base', 'Could not create donation intent. Please try again.')); + Yii::error($transaction->getErrors(), 'donations.intent.save'); + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + $successUrl = Yii::$app->request->hostInfo . $this->contentContainer->createUrl('/donations/donations/complete', [ + 'id' => (int)$transaction->id, + ]); + $cancelUrl = Yii::$app->request->hostInfo . $this->contentContainer->createUrl('/donations/donations/cancel', [ + 'id' => (int)$transaction->id, + ]); + + $gatewayService = new PaymentGatewayService(); + $result = $gatewayService->createCheckout($transaction, $goal, $providerConfig, $successUrl, $cancelUrl); + + $metadata = json_decode((string)$transaction->metadata_json, true); + if (!is_array($metadata)) { + $metadata = []; + } + + $metadata['checkout_result'] = [ + 'success' => (bool)($result['success'] ?? false), + 'provider' => $provider, + 'mode' => $mode, + 'checked_at' => date('c'), + 'error' => (string)($result['error'] ?? ''), + 'status' => (int)($result['status'] ?? 0), + ]; + + $transaction->provider_checkout_id = (string)($result['checkout_id'] ?? $transaction->provider_checkout_id); + $transaction->provider_payment_id = (string)($result['payment_id'] ?? $transaction->provider_payment_id); + $transaction->provider_subscription_id = (string)($result['subscription_id'] ?? $transaction->provider_subscription_id); + $transaction->provider_customer_id = (string)($result['customer_id'] ?? $transaction->provider_customer_id); + $transaction->metadata_json = json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if (!(bool)($result['success'] ?? false)) { + $transaction->status = DonationTransaction::STATUS_FAILED; + $transaction->save(false); + + $this->view->error(Yii::t('DonationsModule.base', 'Unable to start checkout: {message}', [ + 'message' => (string)($result['error'] ?? 'Unknown provider error.'), + ])); + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + $transaction->save(false); + + $redirectUrl = (string)($result['redirect_url'] ?? ''); + if ($redirectUrl === '') { + $this->view->error(Yii::t('DonationsModule.base', 'Provider did not return a checkout URL.')); + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + return $this->redirect($redirectUrl); + } + + public function actionComplete($id) + { + if (!$this->isSchemaReady()) { + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + $transaction = DonationTransaction::findOne([ + 'id' => (int)$id, + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ]); + + if ($transaction instanceof DonationTransaction) { + $this->tryCapturePaypalOrderReturn($transaction); + $this->trySettlePaypalRecurringReturn($transaction); + + if ($transaction->status === DonationTransaction::STATUS_PENDING) { + $this->view->info(Yii::t('DonationsModule.base', 'Checkout completed. Awaiting webhook confirmation from {provider}.', [ + 'provider' => strtoupper((string)$transaction->provider), + ])); + } elseif ($transaction->status === DonationTransaction::STATUS_SUCCEEDED) { + $this->view->success(Yii::t('DonationsModule.base', 'Thank you. Your donation has been confirmed.')); + } else { + $this->view->info(Yii::t('DonationsModule.base', 'Checkout returned with status: {status}.', [ + 'status' => (string)$transaction->status, + ])); + } + } + + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + private function tryCapturePaypalOrderReturn(DonationTransaction $transaction): void + { + if ($transaction->status !== DonationTransaction::STATUS_PENDING) { + return; + } + + if ($transaction->provider !== 'paypal' || $transaction->mode !== 'one_time') { + return; + } + + $expectedOrderId = trim((string)$transaction->provider_checkout_id); + if ($expectedOrderId === '') { + return; + } + + $returnToken = trim((string)Yii::$app->request->get('token', '')); + if ($returnToken === '' || $returnToken !== $expectedOrderId) { + return; + } + + $providerConfig = DonationProviderConfig::findOne([ + 'contentcontainer_id' => $transaction->contentcontainer_id, + ]); + + if (!$providerConfig instanceof DonationProviderConfig || (int)$providerConfig->paypal_enabled !== 1) { + return; + } + + $gatewayService = new PaymentGatewayService(); + $captureResult = $gatewayService->capturePayPalOrder($transaction, $providerConfig); + + if (!($captureResult['success'] ?? false)) { + $metadata = json_decode((string)$transaction->metadata_json, true); + if (!is_array($metadata)) { + $metadata = []; + } + + $metadata['paypal_capture_result'] = [ + 'success' => false, + 'checked_at' => date('c'), + 'status' => (int)($captureResult['status'] ?? 0), + 'error' => (string)($captureResult['error'] ?? 'PayPal order capture failed.'), + ]; + + $transaction->metadata_json = json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $transaction->save(false); + + Yii::warning([ + 'transaction_id' => (int)$transaction->id, + 'status' => (int)($captureResult['status'] ?? 0), + 'error' => (string)($captureResult['error'] ?? ''), + ], 'donations.paypal.capture'); + + return; + } + + $transaction->provider_payment_id = (string)($captureResult['payment_id'] ?? $transaction->provider_payment_id); + $settlement = new DonationSettlementService(); + $settlement->markSucceededAndApply($transaction, [ + 'paypal_order_captured_at' => date('c'), + 'paypal_order_already_captured' => !empty($captureResult['already_captured']) ? 1 : 0, + ]); + } + + private function trySettlePaypalRecurringReturn(DonationTransaction $transaction): void + { + if ($transaction->status !== DonationTransaction::STATUS_PENDING) { + return; + } + + if ($transaction->provider !== 'paypal' || $transaction->mode !== 'recurring') { + return; + } + + $expected = trim((string)$transaction->provider_checkout_id); + if ($expected === '') { + return; + } + + $subscriptionId = trim((string)Yii::$app->request->get('subscription_id', '')); + $token = trim((string)Yii::$app->request->get('token', '')); + + if ($subscriptionId !== $expected && $token !== $expected) { + return; + } + + $settlement = new DonationSettlementService(); + $settlement->markSucceededAndApply($transaction, [ + 'paypal_return_confirmed_at' => date('c'), + ]); + } + + public function actionCancel($id) + { + if ($this->isSchemaReady()) { + $transaction = DonationTransaction::findOne([ + 'id' => (int)$id, + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ]); + + if ($transaction instanceof DonationTransaction && $transaction->status === DonationTransaction::STATUS_PENDING) { + $transaction->status = DonationTransaction::STATUS_CANCELLED; + $transaction->save(false); + } + } + + $this->view->info(Yii::t('DonationsModule.base', 'Donation checkout was canceled.')); + return $this->redirect($this->contentContainer->createUrl('/donations/donations/index')); + } + + private function getEnabledProviderOptions(?DonationProviderConfig $providerConfig): array + { + $options = []; + + if ($providerConfig instanceof DonationProviderConfig && (int)$providerConfig->paypal_enabled === 1) { + $options['paypal'] = Yii::t('DonationsModule.base', 'PayPal'); + } + + if ($providerConfig instanceof DonationProviderConfig && (int)$providerConfig->stripe_enabled === 1) { + $options['stripe'] = Yii::t('DonationsModule.base', 'Stripe'); + } + + return $options; + } + + private function getRecurringProviderKeys(?DonationProviderConfig $providerConfig): array + { + $providers = []; + + if ($providerConfig instanceof DonationProviderConfig && (int)$providerConfig->paypal_enabled === 1 && (int)$providerConfig->paypal_recurring_enabled === 1) { + $providers[] = 'paypal'; + } + + if ($providerConfig instanceof DonationProviderConfig && (int)$providerConfig->stripe_enabled === 1 && (int)$providerConfig->stripe_recurring_enabled === 1) { + $providers[] = 'stripe'; + } + + return $providers; + } + + private function isSchemaReady(): bool + { + return Yii::$app->db->schema->getTableSchema(DonationGoal::tableName(), true) !== null + && Yii::$app->db->schema->getTableSchema(DonationProviderConfig::tableName(), true) !== null + && Yii::$app->db->schema->getTableSchema(DonationTransaction::tableName(), true) !== null; + } + + private function canDonateInSpace(): bool + { + if ($this->contentContainer->can(Donate::class)) { + return true; + } + + if ($this->contentContainer instanceof Space && $this->contentContainer->isAdmin()) { + return true; + } + + if ($this->contentContainer instanceof Space) { + $group = $this->contentContainer->getUserGroup(Yii::$app->user->getIdentity()); + return in_array($group, [ + Space::USERGROUP_OWNER, + Space::USERGROUP_ADMIN, + Space::USERGROUP_MODERATOR, + Space::USERGROUP_MEMBER, + Space::USERGROUP_USER, + Space::USERGROUP_GUEST, + ], true); + } + + return false; + } + + private function buildDonationDashboardData(bool $schemaReady, array $goals): array + { + $result = [ + 'isManager' => $this->canManageDonationDashboard(), + 'months' => [], + 'selectedMonth' => '', + 'userRows' => [], + 'userMonthlyHeaders' => [], + 'userGrandTotal' => 0.0, + 'adminRows' => [], + 'adminTotals' => ['target' => 0.0, 'donated' => 0.0, 'percent' => 0.0], + 'ytd' => ['year' => (int)date('Y'), 'donated' => 0.0, 'target' => 0.0, 'percent' => 0.0], + 'previousYear' => null, + ]; + + if (!$schemaReady) { + return $result; + } + + $transactions = DonationTransaction::find() + ->where([ + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + 'status' => DonationTransaction::STATUS_SUCCEEDED, + ]) + ->orderBy(['created_at' => SORT_DESC, 'id' => SORT_DESC]) + ->all(); + + if (empty($transactions)) { + return $result; + } + + $goalById = []; + $goalByAnimalId = []; + foreach ($goals as $goal) { + if (!$goal instanceof DonationGoal) { + continue; + } + + $goalById[(int)$goal->id] = $goal; + if ((string)$goal->goal_type === DonationGoal::TYPE_ANIMAL && (int)$goal->target_animal_id > 0) { + $goalByAnimalId[(int)$goal->target_animal_id] = $goal; + } + } + + $availableMonths = []; + $donationByAnimalMonth = []; + $donationByAnimalYear = []; + $allAnimalIds = []; + + foreach ($transactions as $transaction) { + $animalId = (int)($transaction->target_animal_id ?? 0); + if ($animalId <= 0 && (int)($transaction->goal_id ?? 0) > 0) { + $goal = $goalById[(int)$transaction->goal_id] ?? null; + if ($goal instanceof DonationGoal) { + $animalId = (int)($goal->target_animal_id ?? 0); + } + } + if ($animalId <= 0) { + continue; + } + + $createdAt = trim((string)($transaction->created_at ?? '')); + $timestamp = $createdAt !== '' ? strtotime($createdAt) : false; + if ($timestamp === false) { + continue; + } + + $amount = max(0.0, (float)($transaction->amount ?? 0)); + if ($amount <= 0) { + continue; + } + + $monthKey = date('Y-m', $timestamp); + $yearKey = (int)date('Y', $timestamp); + + $availableMonths[$monthKey] = $monthKey; + $allAnimalIds[$animalId] = $animalId; + + if (!isset($donationByAnimalMonth[$animalId])) { + $donationByAnimalMonth[$animalId] = []; + } + $donationByAnimalMonth[$animalId][$monthKey] = ($donationByAnimalMonth[$animalId][$monthKey] ?? 0.0) + $amount; + + if (!isset($donationByAnimalYear[$animalId])) { + $donationByAnimalYear[$animalId] = []; + } + $donationByAnimalYear[$animalId][$yearKey] = ($donationByAnimalYear[$animalId][$yearKey] ?? 0.0) + $amount; + } + + if (empty($availableMonths)) { + return $result; + } + + rsort($availableMonths, SORT_STRING); + $result['months'] = array_values($availableMonths); + + $requestedMonth = trim((string)Yii::$app->request->get('month', '')); + $selectedMonth = in_array($requestedMonth, $result['months'], true) ? $requestedMonth : $result['months'][0]; + $result['selectedMonth'] = $selectedMonth; + + $animalMetaById = $this->loadAnimalMeta(array_values($allAnimalIds)); + + if (!Yii::$app->user->isGuest) { + $currentUserId = (int)Yii::$app->user->id; + $userAnimalRows = []; + foreach ($transactions as $transaction) { + if ((int)($transaction->donor_user_id ?? 0) !== $currentUserId) { + continue; + } + + $animalId = (int)($transaction->target_animal_id ?? 0); + if ($animalId <= 0 && (int)($transaction->goal_id ?? 0) > 0) { + $goal = $goalById[(int)$transaction->goal_id] ?? null; + if ($goal instanceof DonationGoal) { + $animalId = (int)($goal->target_animal_id ?? 0); + } + } + if ($animalId <= 0) { + continue; + } + + $createdAt = trim((string)($transaction->created_at ?? '')); + $timestamp = $createdAt !== '' ? strtotime($createdAt) : false; + if ($timestamp === false) { + continue; + } + + $amount = max(0.0, (float)($transaction->amount ?? 0)); + if ($amount <= 0) { + continue; + } + + $monthKey = date('Y-m', $timestamp); + $yearKey = (int)date('Y', $timestamp); + $goal = $goalByAnimalId[$animalId] ?? null; + $animalMeta = $animalMetaById[$animalId] ?? null; + + if (!isset($userAnimalRows[$animalId])) { + $userAnimalRows[$animalId] = [ + 'animalId' => $animalId, + 'animalName' => $animalMeta['name'] ?? ('Animal #' . $animalId), + 'animalUrl' => $animalMeta['url'] ?? '', + 'imageUrl' => $animalMeta['image'] ?? '', + 'goalTarget' => $goal instanceof DonationGoal ? (float)$goal->target_amount : 0.0, + 'goalCurrent' => $goal instanceof DonationGoal ? (float)$goal->current_amount : 0.0, + 'goalCurrency' => $goal instanceof DonationGoal ? (string)$goal->currency : 'USD', + 'total' => 0.0, + 'monthly' => [], + 'annual' => [], + ]; + } + + $userAnimalRows[$animalId]['total'] += $amount; + $userAnimalRows[$animalId]['monthly'][$monthKey] = ($userAnimalRows[$animalId]['monthly'][$monthKey] ?? 0.0) + $amount; + $userAnimalRows[$animalId]['annual'][$yearKey] = ($userAnimalRows[$animalId]['annual'][$yearKey] ?? 0.0) + $amount; + } + + uasort($userAnimalRows, static function (array $a, array $b): int { + return strnatcasecmp((string)$a['animalName'], (string)$b['animalName']); + }); + + $result['userRows'] = array_values($userAnimalRows); + $result['userMonthlyHeaders'] = $result['months']; + $result['userGrandTotal'] = array_reduce($result['userRows'], static function (float $carry, array $row): float { + return $carry + (float)($row['total'] ?? 0.0); + }, 0.0); + } + + $adminAnimalIds = array_values(array_unique(array_merge(array_keys($goalByAnimalId), array_keys($donationByAnimalMonth)))); + sort($adminAnimalIds, SORT_NUMERIC); + + $adminRows = []; + $subtotalTarget = 0.0; + $subtotalDonated = 0.0; + foreach ($adminAnimalIds as $animalId) { + $goal = $goalByAnimalId[$animalId] ?? null; + $animalMeta = $animalMetaById[$animalId] ?? null; + $targetAmount = $goal instanceof DonationGoal ? (float)$goal->target_amount : 0.0; + $donatedAmount = (float)($donationByAnimalMonth[$animalId][$selectedMonth] ?? 0.0); + $percent = $targetAmount > 0 ? min(100.0, ($donatedAmount / $targetAmount) * 100.0) : 0.0; + + $adminRows[] = [ + 'animalId' => $animalId, + 'animalName' => $animalMeta['name'] ?? ('Animal #' . $animalId), + 'animalUrl' => $animalMeta['url'] ?? '', + 'target' => $targetAmount, + 'donated' => $donatedAmount, + 'percent' => $percent, + ]; + + $subtotalTarget += $targetAmount; + $subtotalDonated += $donatedAmount; + } + + usort($adminRows, static function (array $a, array $b): int { + return strnatcasecmp((string)$a['animalName'], (string)$b['animalName']); + }); + + $subtotalPercent = $subtotalTarget > 0 ? min(100.0, ($subtotalDonated / $subtotalTarget) * 100.0) : 0.0; + $result['adminRows'] = $adminRows; + $result['adminTotals'] = [ + 'target' => $subtotalTarget, + 'donated' => $subtotalDonated, + 'percent' => $subtotalPercent, + ]; + + $currentYear = (int)date('Y'); + $previousYear = $currentYear - 1; + $ytdDonated = 0.0; + $previousYearDonated = 0.0; + foreach ($donationByAnimalYear as $animalId => $yearRows) { + $ytdDonated += (float)($yearRows[$currentYear] ?? 0.0); + $previousYearDonated += (float)($yearRows[$previousYear] ?? 0.0); + } + + $totalActiveTarget = 0.0; + foreach ($goalByAnimalId as $goal) { + if (!$goal instanceof DonationGoal) { + continue; + } + $totalActiveTarget += max(0.0, (float)$goal->target_amount); + } + + $result['ytd'] = [ + 'year' => $currentYear, + 'donated' => $ytdDonated, + 'target' => $totalActiveTarget, + 'percent' => $totalActiveTarget > 0 ? min(100.0, ($ytdDonated / $totalActiveTarget) * 100.0) : 0.0, + ]; + + if ($previousYearDonated > 0.0) { + $result['previousYear'] = [ + 'year' => $previousYear, + 'donated' => $previousYearDonated, + 'target' => $totalActiveTarget, + 'percent' => $totalActiveTarget > 0 ? min(100.0, ($previousYearDonated / $totalActiveTarget) * 100.0) : 0.0, + ]; + } + + return $result; + } + + private function canManageDonationDashboard(): bool + { + if (!($this->contentContainer instanceof Space) || Yii::$app->user->isGuest) { + return false; + } + + $group = $this->contentContainer->getUserGroup(Yii::$app->user->getIdentity()); + return in_array($group, [ + Space::USERGROUP_OWNER, + Space::USERGROUP_ADMIN, + Space::USERGROUP_MODERATOR, + Space::USERGROUP_MEMBER, + ], true); + } + + private function loadAnimalMeta(array $animalIds): array + { + $result = []; + $animalIds = array_values(array_unique(array_filter(array_map('intval', $animalIds)))); + if (empty($animalIds)) { + return $result; + } + + $animalClass = 'humhub\\modules\\animal_management\\models\\Animal'; + $galleryClass = 'humhub\\modules\\animal_management\\models\\AnimalGalleryItem'; + + if (!class_exists($animalClass) || Yii::$app->db->schema->getTableSchema($animalClass::tableName(), true) === null) { + return $result; + } + + $animals = $animalClass::find()->where([ + 'id' => $animalIds, + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ])->all(); + + foreach ($animals as $animal) { + $animalId = (int)($animal->id ?? 0); + if ($animalId <= 0) { + continue; + } + + $imageUrl = ''; + if (class_exists($galleryClass) && Yii::$app->db->schema->getTableSchema($galleryClass::tableName(), true) !== null) { + $galleryItem = $galleryClass::find()->where(['animal_id' => $animalId])->orderBy(['id' => SORT_DESC])->one(); + if ($galleryItem !== null && method_exists($galleryItem, 'getImageUrl')) { + $imageUrl = trim((string)$galleryItem->getImageUrl()); + } + } + + $displayName = method_exists($animal, 'getDisplayName') + ? (string)$animal->getDisplayName() + : trim((string)($animal->name ?? '')); + if ($displayName === '') { + $displayName = 'Animal #' . $animalId; + } + + $result[$animalId] = [ + 'name' => $displayName, + 'url' => $this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animalId]), + 'image' => $imageUrl, + ]; + } + + return $result; + } + + public function actionStripeWebhook() + { + Yii::$app->response->format = Response::FORMAT_JSON; + + if (!$this->isSchemaReady()) { + Yii::$app->response->statusCode = 400; + return ['ok' => false, 'message' => 'Donations schema not ready']; + } + + $providerConfig = DonationProviderConfig::findOne([ + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ]); + + if (!$providerConfig instanceof DonationProviderConfig || (int)$providerConfig->stripe_enabled !== 1) { + Yii::$app->response->statusCode = 404; + return ['ok' => false, 'message' => 'Stripe is not enabled for this space']; + } + + $payload = (string)Yii::$app->request->getRawBody(); + $signature = (string)Yii::$app->request->headers->get('Stripe-Signature', ''); + + $service = new StripeWebhookService(); + $result = $service->process($payload, $signature, $providerConfig); + + Yii::$app->response->statusCode = (int)($result['status'] ?? 200); + return [ + 'ok' => (bool)($result['ok'] ?? false), + 'message' => (string)($result['message'] ?? ''), + ]; + } + + public function actionPaypalWebhook() + { + Yii::$app->response->format = Response::FORMAT_JSON; + + if (!$this->isSchemaReady()) { + Yii::$app->response->statusCode = 400; + return ['ok' => false, 'message' => 'Donations schema not ready']; + } + + $providerConfig = DonationProviderConfig::findOne([ + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ]); + + if (!$providerConfig instanceof DonationProviderConfig || (int)$providerConfig->paypal_enabled !== 1) { + Yii::$app->response->statusCode = 404; + return ['ok' => false, 'message' => 'PayPal is not enabled for this space']; + } + + $payload = (string)Yii::$app->request->getRawBody(); + $headers = Yii::$app->request->headers->toArray(); + + $service = new PayPalWebhookService(); + $result = $service->process($payload, $headers, $providerConfig); + + Yii::$app->response->statusCode = (int)($result['status'] ?? 200); + return [ + 'ok' => (bool)($result['ok'] ?? false), + 'message' => (string)($result['message'] ?? ''), + ]; + } +} diff --git a/controllers/SettingsController.php b/controllers/SettingsController.php new file mode 100644 index 0000000..d714107 --- /dev/null +++ b/controllers/SettingsController.php @@ -0,0 +1,616 @@ + 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; + } +} diff --git a/events/DonationSettlementEvent.php b/events/DonationSettlementEvent.php new file mode 100644 index 0000000..ed7e0f0 --- /dev/null +++ b/events/DonationSettlementEvent.php @@ -0,0 +1,14 @@ +safeCreateTable('rescue_donation_provider_config', [ + 'id' => $this->primaryKey(), + 'contentcontainer_id' => $this->integer()->notNull(), + 'paypal_enabled' => $this->boolean()->notNull()->defaultValue(0), + 'paypal_recurring_enabled' => $this->boolean()->notNull()->defaultValue(0), + 'paypal_client_id' => $this->string(255)->null(), + 'paypal_client_secret' => $this->string(255)->null(), + 'paypal_webhook_id' => $this->string(255)->null(), + 'stripe_enabled' => $this->boolean()->notNull()->defaultValue(0), + 'stripe_recurring_enabled' => $this->boolean()->notNull()->defaultValue(0), + 'stripe_publishable_key' => $this->string(255)->null(), + 'stripe_secret_key' => $this->string(255)->null(), + 'stripe_webhook_secret' => $this->string(255)->null(), + 'sandbox_mode' => $this->boolean()->notNull()->defaultValue(1), + 'default_currency' => $this->string(8)->notNull()->defaultValue('USD'), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('ux_rescue_donation_provider_config_container', 'rescue_donation_provider_config', 'contentcontainer_id', true); + $this->safeAddForeignKey('fk_rescue_donation_provider_config_container', 'rescue_donation_provider_config', 'contentcontainer_id', 'contentcontainer', 'id', 'CASCADE', 'CASCADE'); + + $this->safeCreateTable('rescue_donation_goal', [ + 'id' => $this->primaryKey(), + 'contentcontainer_id' => $this->integer()->notNull(), + 'goal_type' => $this->string(32)->notNull(), + 'target_animal_id' => $this->integer()->null(), + 'title' => $this->string(190)->notNull(), + 'description' => $this->text()->null(), + 'image_path' => $this->string(255)->null(), + 'target_amount' => $this->decimal(12, 2)->notNull()->defaultValue(0), + 'current_amount' => $this->decimal(12, 2)->notNull()->defaultValue(0), + 'currency' => $this->string(8)->notNull()->defaultValue('USD'), + 'is_active' => $this->boolean()->notNull()->defaultValue(1), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_donation_goal_container', 'rescue_donation_goal', 'contentcontainer_id', false); + $this->safeCreateIndex('idx_rescue_donation_goal_type', 'rescue_donation_goal', 'goal_type', false); + $this->safeCreateIndex('idx_rescue_donation_goal_active', 'rescue_donation_goal', 'is_active', false); + $this->safeCreateIndex('idx_rescue_donation_goal_animal', 'rescue_donation_goal', 'target_animal_id', false); + $this->safeAddForeignKey('fk_rescue_donation_goal_container', 'rescue_donation_goal', 'contentcontainer_id', 'contentcontainer', 'id', 'CASCADE', 'CASCADE'); + + $this->safeCreateTable('rescue_donation_transaction', [ + 'id' => $this->primaryKey(), + 'contentcontainer_id' => $this->integer()->notNull(), + 'donor_user_id' => $this->integer()->null(), + 'provider' => $this->string(32)->notNull(), + 'mode' => $this->string(16)->notNull(), + 'status' => $this->string(32)->notNull(), + 'amount' => $this->decimal(12, 2)->notNull(), + 'currency' => $this->string(8)->notNull()->defaultValue('USD'), + 'is_anonymous' => $this->boolean()->notNull()->defaultValue(0), + 'goal_id' => $this->integer()->null(), + 'goal_type' => $this->string(32)->null(), + 'target_animal_id' => $this->integer()->null(), + 'provider_payment_id' => $this->string(190)->null(), + 'provider_checkout_id' => $this->string(190)->null(), + 'provider_subscription_id' => $this->string(190)->null(), + 'provider_customer_id' => $this->string(190)->null(), + 'metadata_json' => $this->text()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_donation_transaction_container', 'rescue_donation_transaction', 'contentcontainer_id', false); + $this->safeCreateIndex('idx_rescue_donation_transaction_status', 'rescue_donation_transaction', 'status', false); + $this->safeCreateIndex('idx_rescue_donation_transaction_provider', 'rescue_donation_transaction', 'provider', false); + $this->safeCreateIndex('idx_rescue_donation_transaction_goal', 'rescue_donation_transaction', 'goal_id', false); + $this->safeCreateIndex('idx_rescue_donation_transaction_user', 'rescue_donation_transaction', 'donor_user_id', false); + $this->safeAddForeignKey('fk_rescue_donation_transaction_container', 'rescue_donation_transaction', 'contentcontainer_id', 'contentcontainer', 'id', 'CASCADE', 'CASCADE'); + $this->safeAddForeignKey('fk_rescue_donation_transaction_goal', 'rescue_donation_transaction', 'goal_id', 'rescue_donation_goal', 'id', 'SET NULL', 'CASCADE'); + + if ($this->db->getSchema()->getTableSchema('user', true) !== null) { + $this->safeAddForeignKey('fk_rescue_donation_transaction_user', 'rescue_donation_transaction', 'donor_user_id', 'user', 'id', 'SET NULL', 'CASCADE'); + } + + $this->safeCreateTable('rescue_donation_webhook_event', [ + 'id' => $this->primaryKey(), + 'provider' => $this->string(32)->notNull(), + 'provider_event_id' => $this->string(190)->notNull(), + 'event_type' => $this->string(120)->null(), + 'payload_json' => $this->text()->null(), + 'is_processed' => $this->boolean()->notNull()->defaultValue(0), + 'processed_at' => $this->dateTime()->null(), + 'created_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('ux_rescue_donation_webhook_provider_event', 'rescue_donation_webhook_event', ['provider', 'provider_event_id'], true); + $this->safeCreateIndex('idx_rescue_donation_webhook_processed', 'rescue_donation_webhook_event', 'is_processed', false); + + $this->safeCreateTable('rescue_donation_subscription', [ + 'id' => $this->primaryKey(), + 'contentcontainer_id' => $this->integer()->notNull(), + 'donor_user_id' => $this->integer()->null(), + 'provider' => $this->string(32)->notNull(), + 'provider_subscription_id' => $this->string(190)->notNull(), + 'status' => $this->string(32)->notNull(), + 'amount' => $this->decimal(12, 2)->notNull(), + 'currency' => $this->string(8)->notNull()->defaultValue('USD'), + 'interval_unit' => $this->string(16)->notNull()->defaultValue('month'), + 'interval_count' => $this->integer()->notNull()->defaultValue(1), + 'goal_id' => $this->integer()->null(), + 'next_billing_at' => $this->dateTime()->null(), + 'cancelled_at' => $this->dateTime()->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_donation_subscription_container', 'rescue_donation_subscription', 'contentcontainer_id', false); + $this->safeCreateIndex('idx_rescue_donation_subscription_status', 'rescue_donation_subscription', 'status', false); + $this->safeCreateIndex('idx_rescue_donation_subscription_goal', 'rescue_donation_subscription', 'goal_id', false); + $this->safeCreateIndex('ux_rescue_donation_subscription_provider_id', 'rescue_donation_subscription', ['provider', 'provider_subscription_id'], true); + $this->safeAddForeignKey('fk_rescue_donation_subscription_container', 'rescue_donation_subscription', 'contentcontainer_id', 'contentcontainer', 'id', 'CASCADE', 'CASCADE'); + $this->safeAddForeignKey('fk_rescue_donation_subscription_goal', 'rescue_donation_subscription', 'goal_id', 'rescue_donation_goal', 'id', 'SET NULL', 'CASCADE'); + + if ($this->db->getSchema()->getTableSchema('user', true) !== null) { + $this->safeAddForeignKey('fk_rescue_donation_subscription_user', 'rescue_donation_subscription', 'donor_user_id', 'user', 'id', 'SET NULL', 'CASCADE'); + } + + $this->safeCreateTable('rescue_donation_block', [ + 'id' => $this->primaryKey(), + 'contentcontainer_id' => $this->integer()->notNull(), + 'placement' => $this->string(64)->notNull(), + 'goal_id' => $this->integer()->null(), + 'header' => $this->string(190)->null(), + 'is_active' => $this->boolean()->notNull()->defaultValue(1), + 'sort_order' => $this->integer()->notNull()->defaultValue(100), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_donation_block_container_placement', 'rescue_donation_block', ['contentcontainer_id', 'placement'], false); + $this->safeCreateIndex('idx_rescue_donation_block_goal', 'rescue_donation_block', 'goal_id', false); + $this->safeAddForeignKey('fk_rescue_donation_block_container', 'rescue_donation_block', 'contentcontainer_id', 'contentcontainer', 'id', 'CASCADE', 'CASCADE'); + $this->safeAddForeignKey('fk_rescue_donation_block_goal', 'rescue_donation_block', 'goal_id', 'rescue_donation_goal', 'id', 'SET NULL', 'CASCADE'); + } + + public function safeDown() + { + $this->safeDropTable('rescue_donation_block'); + $this->safeDropTable('rescue_donation_subscription'); + $this->safeDropTable('rescue_donation_webhook_event'); + $this->safeDropTable('rescue_donation_transaction'); + $this->safeDropTable('rescue_donation_goal'); + $this->safeDropTable('rescue_donation_provider_config'); + } +} diff --git a/models/DonationBlock.php b/models/DonationBlock.php new file mode 100644 index 0000000..88c9df9 --- /dev/null +++ b/models/DonationBlock.php @@ -0,0 +1,46 @@ + 64], + [['header'], 'string', 'max' => 190], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + $this->updated_at = $now; + + if ($this->sort_order === null) { + $this->sort_order = 100; + } + + if ($this->is_active === null) { + $this->is_active = 1; + } + + return true; + } +} diff --git a/models/DonationGoal.php b/models/DonationGoal.php new file mode 100644 index 0000000..f348e4b --- /dev/null +++ b/models/DonationGoal.php @@ -0,0 +1,69 @@ + 0], + [['goal_type'], 'string', 'max' => 32], + [['title'], 'string', 'max' => 190], + [['image_path'], 'string', 'max' => 255], + [['currency'], 'string', 'max' => 8], + [['goal_type'], 'in', 'range' => array_keys(self::goalTypeOptions())], + ]; + } + + public static function goalTypeOptions(): array + { + return [ + self::TYPE_RESCUE_GENERAL => Yii::t('DonationsModule.base', 'Rescue General'), + self::TYPE_ANIMAL => Yii::t('DonationsModule.base', 'Animal'), + self::TYPE_NEED => Yii::t('DonationsModule.base', 'Need Specific'), + self::TYPE_SPECIAL_NEED => Yii::t('DonationsModule.base', 'Special Need'), + self::TYPE_SPONSORSHIP => Yii::t('DonationsModule.base', 'Sponsorship'), + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + $this->updated_at = $now; + + if (empty($this->currency)) { + $this->currency = 'USD'; + } + + if ($this->is_active === null) { + $this->is_active = 1; + } + + return true; + } +} diff --git a/models/DonationProviderConfig.php b/models/DonationProviderConfig.php new file mode 100644 index 0000000..c963c80 --- /dev/null +++ b/models/DonationProviderConfig.php @@ -0,0 +1,44 @@ + 255], + [['default_currency'], 'string', 'max' => 8], + [['contentcontainer_id'], 'unique'], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + $this->updated_at = $now; + + if (empty($this->default_currency)) { + $this->default_currency = 'USD'; + } + + return true; + } +} diff --git a/models/DonationSubscription.php b/models/DonationSubscription.php new file mode 100644 index 0000000..d4f9c82 --- /dev/null +++ b/models/DonationSubscription.php @@ -0,0 +1,45 @@ + 0.01], + [['provider', 'status'], 'string', 'max' => 32], + [['provider_subscription_id'], 'string', 'max' => 190], + [['currency'], 'string', 'max' => 8], + [['interval_unit'], 'string', 'max' => 16], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + $this->updated_at = $now; + + if (empty($this->currency)) { + $this->currency = 'USD'; + } + + return true; + } +} diff --git a/models/DonationTransaction.php b/models/DonationTransaction.php new file mode 100644 index 0000000..dd02e07 --- /dev/null +++ b/models/DonationTransaction.php @@ -0,0 +1,52 @@ + 0.01], + [['metadata_json'], 'string'], + [['provider', 'status', 'goal_type'], 'string', 'max' => 32], + [['mode'], 'string', 'max' => 16], + [['currency'], 'string', 'max' => 8], + [['provider_payment_id', 'provider_checkout_id', 'provider_subscription_id', 'provider_customer_id'], 'string', 'max' => 190], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + $now = date('Y-m-d H:i:s'); + if ($insert && empty($this->created_at)) { + $this->created_at = $now; + } + $this->updated_at = $now; + + if (empty($this->currency)) { + $this->currency = 'USD'; + } + + return true; + } +} diff --git a/models/DonationWebhookEvent.php b/models/DonationWebhookEvent.php new file mode 100644 index 0000000..be06b60 --- /dev/null +++ b/models/DonationWebhookEvent.php @@ -0,0 +1,43 @@ + 32], + [['provider_event_id'], 'string', 'max' => 190], + [['event_type'], 'string', 'max' => 120], + [['provider', 'provider_event_id'], 'unique', 'targetAttribute' => ['provider', 'provider_event_id']], + ]; + } + + public function beforeSave($insert) + { + if (!parent::beforeSave($insert)) { + return false; + } + + if ($insert && empty($this->created_at)) { + $this->created_at = date('Y-m-d H:i:s'); + } + + if ($this->is_processed === null) { + $this->is_processed = 0; + } + + return true; + } +} diff --git a/models/forms/DonationGoalForm.php b/models/forms/DonationGoalForm.php new file mode 100644 index 0000000..f612bcd --- /dev/null +++ b/models/forms/DonationGoalForm.php @@ -0,0 +1,271 @@ + 0], + [['is_active'], 'boolean'], + [['goal_type'], 'in', 'range' => array_keys(DonationGoal::goalTypeOptions())], + [['title'], 'string', 'max' => 190], + [['image_path'], 'string', 'max' => 255], + [['imageGalleryPath'], 'string', 'max' => 500], + [['imageGalleryPath'], 'validateGalleryImageSelection'], + [['imageFile'], 'file', + 'skipOnEmpty' => true, + 'extensions' => UploadStandards::imageExtensions(), + 'checkExtensionByMimeType' => true, + 'mimeTypes' => UploadStandards::imageMimeTypes(), + 'maxSize' => UploadStandards::IMAGE_MAX_BYTES, + ], + [['target_animal_id'], 'required', 'when' => function (self $model) { + return $model->goal_type === DonationGoal::TYPE_ANIMAL; + }, 'whenClient' => "function () { return $('#donationgoalform-goal_type').val() === 'animal'; }"], + ]; + } + + public function attributeLabels() + { + return [ + 'target_animal_id' => Yii::t('DonationsModule.base', 'Target Animal'), + 'target_amount' => Yii::t('DonationsModule.base', 'Target Amount'), + 'current_amount' => Yii::t('DonationsModule.base', 'Current Amount'), + 'imageGalleryPath' => Yii::t('DonationsModule.base', 'Image'), + 'imageFile' => Yii::t('DonationsModule.base', 'Image Upload'), + ]; + } + + public function loadFromGoal(DonationGoal $goal): void + { + $this->id = (int)$goal->id; + $this->goal_type = (string)$goal->goal_type; + $this->target_animal_id = $goal->target_animal_id !== null ? (int)$goal->target_animal_id : null; + $this->title = (string)$goal->title; + $this->description = (string)$goal->description; + $this->image_path = (string)$goal->image_path; + $this->imageGalleryPath = (string)$goal->image_path; + $this->target_amount = (float)$goal->target_amount; + $this->current_amount = (float)$goal->current_amount; + $this->is_active = (int)$goal->is_active === 1; + } + + public function save(): ?DonationGoal + { + if (!$this->validate()) { + return null; + } + + $goal = null; + if ($this->id) { + $goal = DonationGoal::findOne([ + 'id' => $this->id, + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ]); + } + + if (!$goal instanceof DonationGoal) { + $goal = new DonationGoal(); + $goal->contentcontainer_id = $this->contentContainer->contentcontainer_id; + } + + $goal->goal_type = $this->goal_type; + $goal->target_animal_id = $this->goal_type === DonationGoal::TYPE_ANIMAL ? (int)$this->target_animal_id : null; + $goal->title = trim($this->title); + $goal->description = trim($this->description); + $goal->image_path = trim((string)$goal->image_path); + $goal->target_amount = $this->target_amount; + $goal->current_amount = $this->current_amount; + $goal->currency = $this->resolveDefaultCurrency(); + $goal->is_active = $this->is_active ? 1 : 0; + + if (!$goal->save()) { + $this->addErrors($goal->getErrors()); + return null; + } + + if ($this->imageFile instanceof UploadedFile) { + $stored = $this->storeImage($this->imageFile, $goal); + if ($stored === null) { + $this->addError('imageFile', Yii::t('DonationsModule.base', 'Could not upload goal image.')); + return null; + } + $goal->image_path = $stored; + } else { + $selectedFromGallery = trim((string)$this->imageGalleryPath); + if ($selectedFromGallery !== '') { + $goal->image_path = $selectedFromGallery; + } + } + + if (!$goal->save()) { + $this->addErrors($goal->getErrors()); + return null; + } + + return $goal; + } + + public function validateGalleryImageSelection(string $attribute): void + { + $value = trim((string)$this->$attribute); + if ($value === '') { + return; + } + + if ($this->goal_type !== DonationGoal::TYPE_ANIMAL || (int)$this->target_animal_id <= 0) { + if (preg_match('/^(https?:\/\/|\/)/i', $value)) { + return; + } + + $this->addError($attribute, Yii::t('DonationsModule.base', 'Please select a valid image source.')); + return; + } + + $options = $this->getGalleryImageOptionsForAnimal((int)$this->target_animal_id); + if (!isset($options[$value])) { + $this->addError($attribute, Yii::t('DonationsModule.base', 'Please select an image from this animal gallery or upload a new one.')); + } + } + + public function getAnimalOptions(): array + { + $animalClass = 'humhub\\modules\\animal_management\\models\\Animal'; + if (!class_exists($animalClass)) { + return []; + } + + $tableName = $animalClass::tableName(); + if (Yii::$app->db->schema->getTableSchema($tableName, true) === null) { + return []; + } + + $animals = $animalClass::find() + ->where(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]) + ->orderBy(['name' => SORT_ASC, 'id' => SORT_ASC]) + ->all(); + + $options = []; + foreach ($animals as $animal) { + $label = method_exists($animal, 'getDisplayName') + ? (string)$animal->getDisplayName() + : trim((string)($animal->name ?? '')); + + if ($label === '') { + $label = 'Animal #' . (int)$animal->id; + } + + $options[(int)$animal->id] = $label; + } + + return $options; + } + + public function getGalleryImageOptionsForAnimal(int $animalId): array + { + $animalGalleryClass = 'humhub\\modules\\animal_management\\models\\AnimalGalleryItem'; + if (!class_exists($animalGalleryClass)) { + return []; + } + + if (Yii::$app->db->schema->getTableSchema($animalGalleryClass::tableName(), true) === null) { + return []; + } + + $items = $animalGalleryClass::find() + ->where(['animal_id' => $animalId]) + ->orderBy(['id' => SORT_DESC]) + ->all(); + + $options = []; + foreach ($items as $item) { + $url = trim((string)$item->getImageUrl()); + if ($url === '') { + continue; + } + + $options[$url] = Yii::t('DonationsModule.base', 'Gallery Image #{id}', [ + 'id' => (int)$item->id, + ]); + } + + return $options; + } + + private function resolveDefaultCurrency(): string + { + $config = DonationProviderConfig::findOne([ + 'contentcontainer_id' => $this->contentContainer->contentcontainer_id, + ]); + + if ($config instanceof DonationProviderConfig) { + $currency = strtoupper(trim((string)$config->default_currency)); + if ($currency !== '') { + return $currency; + } + } + + return 'USD'; + } + + private function storeImage(UploadedFile $file, DonationGoal $goal): ?string + { + $random = Yii::$app->security->generateRandomString(8); + $extension = strtolower((string)$file->extension); + $fileName = 'goal-' . (int)$goal->id . '-' . time() . '-' . $random . '.' . $extension; + + $candidateDirs = [ + '/uploads/donations/goals/' . (int)$goal->id, + '/uploads/donations-runtime/goals/' . (int)$goal->id, + ]; + + foreach ($candidateDirs as $relativeDir) { + $absoluteDir = Yii::getAlias('@webroot') . $relativeDir; + try { + FileHelper::createDirectory($absoluteDir, 0775, true); + } catch (\Throwable $e) { + Yii::warning($e->getMessage(), 'donations.goal_image_upload_dir'); + continue; + } + + if (!is_dir($absoluteDir) || !is_writable($absoluteDir)) { + continue; + } + + $absolutePath = $absoluteDir . '/' . $fileName; + if ($file->saveAs($absolutePath)) { + return $relativeDir . '/' . $fileName; + } + } + + return null; + } +} diff --git a/models/forms/ProviderSettingsForm.php b/models/forms/ProviderSettingsForm.php new file mode 100644 index 0000000..db1b011 --- /dev/null +++ b/models/forms/ProviderSettingsForm.php @@ -0,0 +1,209 @@ + 0, 'max' => 600], + [['animal_donation_form_header'], 'string', 'max' => 255], + [[ + 'paypal_sandbox_client_id', + 'paypal_sandbox_client_secret', + 'paypal_sandbox_webhook_id', + 'paypal_live_client_id', + 'paypal_live_client_secret', + 'paypal_live_webhook_id', + 'stripe_sandbox_publishable_key', + 'stripe_sandbox_secret_key', + 'stripe_sandbox_webhook_secret', + 'stripe_live_publishable_key', + 'stripe_live_secret_key', + 'stripe_live_webhook_secret', + ], 'string', 'max' => 255], + [['default_currency'], 'string', 'max' => 8], + [['default_currency'], 'match', 'pattern' => '/^[A-Z]{3}$/'], + ]; + } + + public function attributeLabels() + { + return [ + 'sandbox_mode' => Yii::t('DonationsModule.base', 'Use Sandbox Mode'), + 'default_currency' => Yii::t('DonationsModule.base', 'Default Currency'), + 'animal_tile_extra_height_px' => Yii::t('DonationsModule.base', 'Animal Tile Extra Height (px)'), + 'animal_donation_form_header' => Yii::t('DonationsModule.base', 'Animal Donation Form Header'), + 'paypal_enabled' => Yii::t('DonationsModule.base', 'Enable PayPal'), + 'paypal_recurring_enabled' => Yii::t('DonationsModule.base', 'Enable PayPal Recurring'), + 'paypal_sandbox_client_id' => Yii::t('DonationsModule.base', 'Sandbox Client ID'), + 'paypal_sandbox_client_secret' => Yii::t('DonationsModule.base', 'Sandbox Client Secret'), + 'paypal_sandbox_webhook_id' => Yii::t('DonationsModule.base', 'Sandbox Webhook ID'), + 'paypal_live_client_id' => Yii::t('DonationsModule.base', 'Live Client ID'), + 'paypal_live_client_secret' => Yii::t('DonationsModule.base', 'Live Client Secret'), + 'paypal_live_webhook_id' => Yii::t('DonationsModule.base', 'Live Webhook ID'), + 'stripe_enabled' => Yii::t('DonationsModule.base', 'Enable Stripe'), + 'stripe_recurring_enabled' => Yii::t('DonationsModule.base', 'Enable Stripe Recurring'), + 'stripe_sandbox_publishable_key' => Yii::t('DonationsModule.base', 'Sandbox Publishable Key'), + 'stripe_sandbox_secret_key' => Yii::t('DonationsModule.base', 'Sandbox Secret Key'), + 'stripe_sandbox_webhook_secret' => Yii::t('DonationsModule.base', 'Sandbox Webhook Secret'), + 'stripe_live_publishable_key' => Yii::t('DonationsModule.base', 'Live Publishable Key'), + 'stripe_live_secret_key' => Yii::t('DonationsModule.base', 'Live Secret Key'), + 'stripe_live_webhook_secret' => Yii::t('DonationsModule.base', 'Live Webhook Secret'), + ]; + } + + public function loadValues(): void + { + $record = null; + if ($this->hasProviderConfigTable()) { + $record = DonationProviderConfig::findOne(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]); + } + + $settings = Yii::$app->getModule('donations')->settings->contentContainer($this->contentContainer); + + $this->sandbox_mode = (bool)($settings->get('sandbox_mode', $record->sandbox_mode ?? 1)); + $this->default_currency = strtoupper((string)$settings->get('default_currency', $record->default_currency ?? 'USD')); + $this->animal_tile_extra_height_px = (int)$settings->get('animal_tile_extra_height_px', 0); + $this->animal_donation_form_header = (string)$settings->get('animal_donation_form_header', Yii::t('DonationsModule.base', 'Your Donation directly supports [animal-name]')); + + $this->paypal_enabled = (bool)($settings->get('paypal_enabled', $record->paypal_enabled ?? 0)); + $this->paypal_recurring_enabled = (bool)($settings->get('paypal_recurring_enabled', $record->paypal_recurring_enabled ?? 0)); + $legacyPaypalClientId = (string)$settings->get('paypal_client_id', $record->paypal_client_id ?? ''); + $legacyPaypalClientSecret = (string)$settings->get('paypal_client_secret', $record->paypal_client_secret ?? ''); + $legacyPaypalWebhookId = (string)$settings->get('paypal_webhook_id', $record->paypal_webhook_id ?? ''); + + $this->paypal_sandbox_client_id = (string)$settings->get('paypal_sandbox_client_id', $legacyPaypalClientId); + $this->paypal_sandbox_client_secret = (string)$settings->get('paypal_sandbox_client_secret', $legacyPaypalClientSecret); + $this->paypal_sandbox_webhook_id = (string)$settings->get('paypal_sandbox_webhook_id', $legacyPaypalWebhookId); + $this->paypal_live_client_id = (string)$settings->get('paypal_live_client_id', $legacyPaypalClientId); + $this->paypal_live_client_secret = (string)$settings->get('paypal_live_client_secret', $legacyPaypalClientSecret); + $this->paypal_live_webhook_id = (string)$settings->get('paypal_live_webhook_id', $legacyPaypalWebhookId); + + $this->stripe_enabled = (bool)($settings->get('stripe_enabled', $record->stripe_enabled ?? 0)); + $this->stripe_recurring_enabled = (bool)($settings->get('stripe_recurring_enabled', $record->stripe_recurring_enabled ?? 0)); + $legacyStripePublishable = (string)$settings->get('stripe_publishable_key', $record->stripe_publishable_key ?? ''); + $legacyStripeSecret = (string)$settings->get('stripe_secret_key', $record->stripe_secret_key ?? ''); + $legacyStripeWebhook = (string)$settings->get('stripe_webhook_secret', $record->stripe_webhook_secret ?? ''); + + $this->stripe_sandbox_publishable_key = (string)$settings->get('stripe_sandbox_publishable_key', $legacyStripePublishable); + $this->stripe_sandbox_secret_key = (string)$settings->get('stripe_sandbox_secret_key', $legacyStripeSecret); + $this->stripe_sandbox_webhook_secret = (string)$settings->get('stripe_sandbox_webhook_secret', $legacyStripeWebhook); + $this->stripe_live_publishable_key = (string)$settings->get('stripe_live_publishable_key', $legacyStripePublishable); + $this->stripe_live_secret_key = (string)$settings->get('stripe_live_secret_key', $legacyStripeSecret); + $this->stripe_live_webhook_secret = (string)$settings->get('stripe_live_webhook_secret', $legacyStripeWebhook); + } + + public function save(): bool + { + if (!$this->validate()) { + return false; + } + + $settings = Yii::$app->getModule('donations')->settings->contentContainer($this->contentContainer); + + $settings->set('sandbox_mode', $this->sandbox_mode ? 1 : 0); + $currency = strtoupper(trim((string)$this->default_currency)) ?: 'USD'; + $settings->set('default_currency', $currency); + $settings->set('animal_tile_extra_height_px', (int)$this->animal_tile_extra_height_px); + $settings->set('animal_donation_form_header', trim((string)$this->animal_donation_form_header)); + + $settings->set('paypal_enabled', $this->paypal_enabled ? 1 : 0); + $settings->set('paypal_recurring_enabled', $this->paypal_recurring_enabled ? 1 : 0); + $settings->set('paypal_sandbox_client_id', trim((string)$this->paypal_sandbox_client_id)); + $settings->set('paypal_sandbox_client_secret', trim((string)$this->paypal_sandbox_client_secret)); + $settings->set('paypal_sandbox_webhook_id', trim((string)$this->paypal_sandbox_webhook_id)); + $settings->set('paypal_live_client_id', trim((string)$this->paypal_live_client_id)); + $settings->set('paypal_live_client_secret', trim((string)$this->paypal_live_client_secret)); + $settings->set('paypal_live_webhook_id', trim((string)$this->paypal_live_webhook_id)); + + $settings->set('stripe_enabled', $this->stripe_enabled ? 1 : 0); + $settings->set('stripe_recurring_enabled', $this->stripe_recurring_enabled ? 1 : 0); + $settings->set('stripe_sandbox_publishable_key', trim((string)$this->stripe_sandbox_publishable_key)); + $settings->set('stripe_sandbox_secret_key', trim((string)$this->stripe_sandbox_secret_key)); + $settings->set('stripe_sandbox_webhook_secret', trim((string)$this->stripe_sandbox_webhook_secret)); + $settings->set('stripe_live_publishable_key', trim((string)$this->stripe_live_publishable_key)); + $settings->set('stripe_live_secret_key', trim((string)$this->stripe_live_secret_key)); + $settings->set('stripe_live_webhook_secret', trim((string)$this->stripe_live_webhook_secret)); + + $paypalClientId = trim((string)($this->sandbox_mode ? $this->paypal_sandbox_client_id : $this->paypal_live_client_id)); + $paypalClientSecret = trim((string)($this->sandbox_mode ? $this->paypal_sandbox_client_secret : $this->paypal_live_client_secret)); + $paypalWebhookId = trim((string)($this->sandbox_mode ? $this->paypal_sandbox_webhook_id : $this->paypal_live_webhook_id)); + + $stripePublishable = trim((string)($this->sandbox_mode ? $this->stripe_sandbox_publishable_key : $this->stripe_live_publishable_key)); + $stripeSecret = trim((string)($this->sandbox_mode ? $this->stripe_sandbox_secret_key : $this->stripe_live_secret_key)); + $stripeWebhook = trim((string)($this->sandbox_mode ? $this->stripe_sandbox_webhook_secret : $this->stripe_live_webhook_secret)); + + $settings->set('paypal_client_id', $paypalClientId); + $settings->set('paypal_client_secret', $paypalClientSecret); + $settings->set('paypal_webhook_id', $paypalWebhookId); + $settings->set('stripe_publishable_key', $stripePublishable); + $settings->set('stripe_secret_key', $stripeSecret); + $settings->set('stripe_webhook_secret', $stripeWebhook); + + if ($this->hasProviderConfigTable()) { + $record = DonationProviderConfig::findOne(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]); + if (!$record instanceof DonationProviderConfig) { + $record = new DonationProviderConfig(); + $record->contentcontainer_id = $this->contentContainer->contentcontainer_id; + } + + $record->sandbox_mode = $this->sandbox_mode ? 1 : 0; + $record->paypal_enabled = $this->paypal_enabled ? 1 : 0; + $record->paypal_recurring_enabled = $this->paypal_recurring_enabled ? 1 : 0; + $record->paypal_client_id = $paypalClientId; + $record->paypal_client_secret = $paypalClientSecret; + $record->paypal_webhook_id = $paypalWebhookId; + $record->stripe_enabled = $this->stripe_enabled ? 1 : 0; + $record->stripe_recurring_enabled = $this->stripe_recurring_enabled ? 1 : 0; + $record->stripe_publishable_key = $stripePublishable; + $record->stripe_secret_key = $stripeSecret; + $record->stripe_webhook_secret = $stripeWebhook; + $record->default_currency = $currency; + + if (!$record->save()) { + $this->addErrors($record->getErrors()); + return false; + } + } + + return true; + } + + private function hasProviderConfigTable(): bool + { + return Yii::$app->db->schema->getTableSchema(DonationProviderConfig::tableName(), true) !== null; + } +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..dde32de --- /dev/null +++ b/module.json @@ -0,0 +1,15 @@ +{ + "id": "donations", + "name": "Donations", + "description": "Rescue and animal donation goals with Stripe and PayPal donation flows.", + "keywords": ["donations", "stripe", "paypal", "rescue", "animals"], + "version": "0.1.0", + "humhub": { + "minVersion": "1.14" + }, + "authors": [ + { + "name": "Kelin Rescue Hub" + } + ] +} diff --git a/notifications/DonationNotificationCategory.php b/notifications/DonationNotificationCategory.php new file mode 100644 index 0000000..ed8a13c --- /dev/null +++ b/notifications/DonationNotificationCategory.php @@ -0,0 +1,21 @@ +payloadString('spaceGuid', $this->spaceGuid); + $space = Space::findOne(['guid' => $spaceGuid]); + + if ($space instanceof Space) { + return $space->createUrl('/donations/settings/history'); + } + + return Url::to(['/donations/settings/history', 'sguid' => $spaceGuid]); + } + + public function html() + { + $goalTitle = $this->payloadString('goalTitle', $this->goalTitle); + $amountLabel = $this->payloadString('amountLabel', $this->amountLabel); + + if ($this->originator) { + return Yii::t('DonationsModule.base', '{displayName} processed a refund of {amount} for {goal}.', [ + 'displayName' => Html::tag('strong', Html::encode($this->originator->displayName)), + 'amount' => Html::tag('strong', Html::encode($amountLabel)), + 'goal' => Html::tag('strong', Html::encode($goalTitle)), + ]); + } + + return Yii::t('DonationsModule.base', 'A refund of {amount} was processed for {goal}.', [ + 'amount' => Html::tag('strong', Html::encode($amountLabel)), + 'goal' => Html::tag('strong', Html::encode($goalTitle)), + ]); + } + + public function getMailSubject() + { + $goalTitle = $this->payloadString('goalTitle', $this->goalTitle); + $amountLabel = $this->payloadString('amountLabel', $this->amountLabel); + + return Yii::t('DonationsModule.base', 'Donation refunded: {amount} for {goal}', [ + 'amount' => $amountLabel, + 'goal' => $goalTitle, + ]); + } + + public function __serialize(): array + { + $data = parent::__serialize(); + $data['spaceGuid'] = $this->spaceGuid; + $data['goalTitle'] = $this->goalTitle; + $data['amountLabel'] = $this->amountLabel; + $data['payload'] = $this->payload; + + return $data; + } + + public function __unserialize($unserializedArr) + { + parent::__unserialize($unserializedArr); + + $this->spaceGuid = (string)($unserializedArr['spaceGuid'] ?? ''); + $this->goalTitle = (string)($unserializedArr['goalTitle'] ?? ''); + $this->amountLabel = (string)($unserializedArr['amountLabel'] ?? ''); + + if (isset($unserializedArr['payload']) && is_array($unserializedArr['payload'])) { + $this->payload = $unserializedArr['payload']; + } + } + + private function payloadString(string $key, string $fallback = ''): string + { + if (is_array($this->payload) && array_key_exists($key, $this->payload)) { + return trim((string)$this->payload[$key]); + } + + if ($this->record !== null && !empty($this->record->payload)) { + try { + $decoded = Json::decode((string)$this->record->payload); + if (is_array($decoded)) { + $this->payload = $decoded; + if (array_key_exists($key, $decoded)) { + return trim((string)$decoded[$key]); + } + } + } catch (\Throwable $e) { + } + } + + return trim($fallback); + } +} diff --git a/notifications/DonationSucceededNotification.php b/notifications/DonationSucceededNotification.php new file mode 100644 index 0000000..74b2bf9 --- /dev/null +++ b/notifications/DonationSucceededNotification.php @@ -0,0 +1,114 @@ +payloadString('spaceGuid', $this->spaceGuid); + $space = Space::findOne(['guid' => $spaceGuid]); + + if ($space instanceof Space) { + return $space->createUrl('/donations/donations/index'); + } + + return Url::to(['/donations/donations/index', 'sguid' => $spaceGuid]); + } + + public function html() + { + $goalTitle = $this->payloadString('goalTitle', $this->goalTitle); + $amountLabel = $this->payloadString('amountLabel', $this->amountLabel); + + if ($this->originator) { + return Yii::t('DonationsModule.base', '{displayName} completed a donation of {amount} for {goal}.', [ + 'displayName' => Html::tag('strong', Html::encode($this->originator->displayName)), + 'amount' => Html::tag('strong', Html::encode($amountLabel)), + 'goal' => Html::tag('strong', Html::encode($goalTitle)), + ]); + } + + return Yii::t('DonationsModule.base', 'A donation of {amount} was completed for {goal}.', [ + 'amount' => Html::tag('strong', Html::encode($amountLabel)), + 'goal' => Html::tag('strong', Html::encode($goalTitle)), + ]); + } + + public function getMailSubject() + { + $goalTitle = $this->payloadString('goalTitle', $this->goalTitle); + $amountLabel = $this->payloadString('amountLabel', $this->amountLabel); + + return Yii::t('DonationsModule.base', 'Donation confirmed: {amount} for {goal}', [ + 'amount' => $amountLabel, + 'goal' => $goalTitle, + ]); + } + + public function __serialize(): array + { + $data = parent::__serialize(); + $data['spaceGuid'] = $this->spaceGuid; + $data['goalTitle'] = $this->goalTitle; + $data['amountLabel'] = $this->amountLabel; + $data['payload'] = $this->payload; + + return $data; + } + + public function __unserialize($unserializedArr) + { + parent::__unserialize($unserializedArr); + + $this->spaceGuid = (string)($unserializedArr['spaceGuid'] ?? ''); + $this->goalTitle = (string)($unserializedArr['goalTitle'] ?? ''); + $this->amountLabel = (string)($unserializedArr['amountLabel'] ?? ''); + + if (isset($unserializedArr['payload']) && is_array($unserializedArr['payload'])) { + $this->payload = $unserializedArr['payload']; + } + } + + private function payloadString(string $key, string $fallback = ''): string + { + if (is_array($this->payload) && array_key_exists($key, $this->payload)) { + return trim((string)$this->payload[$key]); + } + + if ($this->record !== null && !empty($this->record->payload)) { + try { + $decoded = Json::decode((string)$this->record->payload); + if (is_array($decoded)) { + $this->payload = $decoded; + if (array_key_exists($key, $decoded)) { + return trim((string)$decoded[$key]); + } + } + } catch (\Throwable $e) { + } + } + + return trim($fallback); + } +} diff --git a/permissions/Donate.php b/permissions/Donate.php new file mode 100644 index 0000000..a73441f --- /dev/null +++ b/permissions/Donate.php @@ -0,0 +1,30 @@ +publishAnimalProgressEntry($transaction, $goal, $payload, 'succeeded'); + } + + public function applyRefundedIntegration(DonationTransaction $transaction, ?DonationGoal $goal, array $payload): array + { + if (!empty($payload['animal_progress_refunded_created'])) { + return $payload; + } + + return $this->publishAnimalProgressEntry($transaction, $goal, $payload, 'refunded'); + } + + private function publishAnimalProgressEntry(DonationTransaction $transaction, ?DonationGoal $goal, array $payload, string $mode): array + { + $goal = $goal instanceof DonationGoal ? $goal : DonationGoal::findOne([ + 'id' => (int)$transaction->goal_id, + 'contentcontainer_id' => (int)$transaction->contentcontainer_id, + ]); + + if (!$goal instanceof DonationGoal || (string)$goal->goal_type !== DonationGoal::TYPE_ANIMAL || (int)$goal->target_animal_id <= 0) { + return $payload; + } + + $animalClass = 'humhub\\modules\\animal_management\\models\\Animal'; + $updateClass = 'humhub\\modules\\animal_management\\models\\AnimalProgressUpdate'; + $publisherClass = 'humhub\\modules\\animal_management\\services\\AnimalStreamPublisherService'; + + if (!class_exists($animalClass) || !class_exists($updateClass)) { + return $payload; + } + + if (Yii::$app->db->schema->getTableSchema($animalClass::tableName(), true) === null + || Yii::$app->db->schema->getTableSchema($updateClass::tableName(), true) === null) { + return $payload; + } + + $animal = $animalClass::findOne([ + 'id' => (int)$goal->target_animal_id, + 'contentcontainer_id' => (int)$transaction->contentcontainer_id, + ]); + + if ($animal === null) { + $payload['animal_progress_' . $mode . '_error'] = 'Target animal not found.'; + return $payload; + } + + $label = number_format((float)$transaction->amount, 2) . ' ' . strtoupper((string)$transaction->currency); + $note = $mode === 'refunded' + ? Yii::t('DonationsModule.base', 'Donation refunded: {amount} for goal "{goalTitle}".', [ + 'amount' => $label, + 'goalTitle' => (string)$goal->title, + ]) + : Yii::t('DonationsModule.base', 'Donation received: {amount} for goal "{goalTitle}".', [ + 'amount' => $label, + 'goalTitle' => (string)$goal->title, + ]); + + $update = new $updateClass(); + $update->animal_id = (int)$animal->id; + $update->created_by = (int)$transaction->donor_user_id > 0 ? (int)$transaction->donor_user_id : null; + $update->update_at = date('Y-m-d H:i:s'); + $update->behavior_notes = $note; + $update->post_to_space_feed = 1; + $update->post_to_animal_feed = 1; + + if (!$update->save()) { + Yii::warning([ + 'message' => 'Could not save animal progress update for donation integration.', + 'transaction_id' => (int)$transaction->id, + 'mode' => $mode, + 'errors' => $update->getErrors(), + ], 'donations.animal_integration'); + + $payload['animal_progress_' . $mode . '_error'] = 'Could not save animal progress update.'; + return $payload; + } + + if (class_exists($publisherClass) && method_exists($publisherClass, 'publishProgressUpdate')) { + try { + $publisherClass::publishProgressUpdate($animal, $update); + } catch (\Throwable $e) { + Yii::warning([ + 'message' => 'Could not publish animal stream entry for donation integration.', + 'transaction_id' => (int)$transaction->id, + 'mode' => $mode, + 'exception' => $e->getMessage(), + ], 'donations.animal_integration'); + } + } + + $payload['animal_progress_' . $mode . '_created'] = 1; + $payload['animal_progress_' . $mode . '_id'] = (int)$update->id; + $payload['animal_progress_' . $mode . '_at'] = date('c'); + + return $payload; + } +} diff --git a/services/DonationNotificationService.php b/services/DonationNotificationService.php new file mode 100644 index 0000000..0dcc825 --- /dev/null +++ b/services/DonationNotificationService.php @@ -0,0 +1,146 @@ +sendNotifications($transaction, $goal, $payload, 'succeeded'); + } + + public function applyRefundedNotifications(DonationTransaction $transaction, ?DonationGoal $goal, array $payload): array + { + if (!empty($payload['donation_refunded_notifications_sent'])) { + return $payload; + } + + return $this->sendNotifications($transaction, $goal, $payload, 'refunded'); + } + + private function sendNotifications(DonationTransaction $transaction, ?DonationGoal $goal, array $payload, string $mode): array + { + $space = Space::findOne(['contentcontainer_id' => (int)$transaction->contentcontainer_id]); + if (!$space instanceof Space) { + return $payload; + } + + $goal = $goal instanceof DonationGoal ? $goal : DonationGoal::findOne([ + 'id' => (int)$transaction->goal_id, + 'contentcontainer_id' => (int)$transaction->contentcontainer_id, + ]); + + $goalTitle = $goal instanceof DonationGoal + ? (string)$goal->title + : Yii::t('DonationsModule.base', 'Donation Goal #{id}', ['id' => (int)$transaction->goal_id]); + + $amountLabel = number_format((float)$transaction->amount, 2) . ' ' . strtoupper((string)$transaction->currency); + $originator = $this->findUser((int)$transaction->donor_user_id); + + $managerRecipients = $this->privilegedUsersForSpace($space); + $donorRecipient = $originator; + + $sent = 0; + $failures = 0; + + $send = function (User $recipient, bool $isDonorRecipient) use ($mode, $space, $goalTitle, $amountLabel, $originator, &$sent, &$failures): void { + try { + $notification = $mode === 'refunded' + ? DonationRefundedNotification::instance() + : DonationSucceededNotification::instance(); + + if ($originator instanceof User && !$isDonorRecipient) { + $notification->from($originator); + } + + $notification->spaceGuid = (string)$space->guid; + $notification->goalTitle = $goalTitle; + $notification->amountLabel = $amountLabel; + $notification->payload([ + 'spaceGuid' => $notification->spaceGuid, + 'goalTitle' => $notification->goalTitle, + 'amountLabel' => $notification->amountLabel, + ]); + $notification->send($recipient); + $sent++; + } catch (\Throwable $e) { + Yii::warning([ + 'message' => 'Could not send donation notification.', + 'mode' => $mode, + 'transaction_id' => (int)$transaction->id, + 'recipient_id' => (int)$recipient->id, + 'exception' => $e->getMessage(), + ], 'donations.notifications'); + $failures++; + } + }; + + if ($donorRecipient instanceof User) { + $send($donorRecipient, true); + } + + foreach ($managerRecipients as $recipient) { + if ($donorRecipient instanceof User && (int)$recipient->id === (int)$donorRecipient->id) { + continue; + } + + $send($recipient, false); + } + + $prefix = $mode === 'refunded' ? 'donation_refunded_notifications' : 'donation_succeeded_notifications'; + $payload[$prefix . '_sent'] = 1; + $payload[$prefix . '_sent_count'] = $sent; + $payload[$prefix . '_failed_count'] = $failures; + $payload[$prefix . '_at'] = date('c'); + + return $payload; + } + + private function findUser(int $userId): ?User + { + if ($userId <= 0) { + return null; + } + + $user = User::findOne(['id' => $userId]); + if (!$user instanceof User || (int)$user->status !== User::STATUS_ENABLED) { + return null; + } + + return $user; + } + + private function privilegedUsersForSpace(Space $space): array + { + $recipients = []; + + foreach ($space->getPrivilegedGroupUsers() as $users) { + foreach ($users as $user) { + if ($user instanceof User && (int)$user->status === User::STATUS_ENABLED) { + $recipients[(int)$user->id] = $user; + } + } + } + + if (empty($recipients)) { + $owner = $space->getOwnerUser()->one(); + if ($owner instanceof User && (int)$owner->status === User::STATUS_ENABLED) { + $recipients[(int)$owner->id] = $owner; + } + } + + return array_values($recipients); + } +} diff --git a/services/DonationSettlementService.php b/services/DonationSettlementService.php new file mode 100644 index 0000000..a433ff1 --- /dev/null +++ b/services/DonationSettlementService.php @@ -0,0 +1,108 @@ +decodeMetadata($transaction->metadata_json); + $applied = !empty($payload['goal_amount_applied']); + $goal = null; + + if ((int)$transaction->goal_id > 0) { + $goal = DonationGoal::findOne([ + 'id' => (int)$transaction->goal_id, + 'contentcontainer_id' => (int)$transaction->contentcontainer_id, + ]); + } + + if (!$applied) { + if ($goal instanceof DonationGoal) { + $goal->current_amount = round((float)$goal->current_amount + (float)$transaction->amount, 2); + $goal->save(false); + + $payload['goal_amount_applied'] = 1; + $payload['goal_amount_applied_at'] = date('c'); + $payload['goal_amount_applied_value'] = (float)$transaction->amount; + } + } + + foreach ($metadata as $key => $value) { + $payload[$key] = $value; + } + + $transaction->status = DonationTransaction::STATUS_SUCCEEDED; + $event = new DonationSettlementEvent(); + $event->transaction = $transaction; + $event->goal = $goal; + $event->payload = $payload; + + Event::trigger(self::class, self::EVENT_AFTER_SUCCEEDED, $event); + if (is_array($event->payload)) { + $payload = $event->payload; + } + + $transaction->metadata_json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $transaction->save(false); + } + + public function markRefundedAndRevert(DonationTransaction $transaction, array $metadata = []): void + { + $payload = $this->decodeMetadata($transaction->metadata_json); + $applied = !empty($payload['goal_amount_applied']); + $reverted = !empty($payload['goal_amount_reverted']); + $goal = null; + + if ((int)$transaction->goal_id > 0) { + $goal = DonationGoal::findOne([ + 'id' => (int)$transaction->goal_id, + 'contentcontainer_id' => (int)$transaction->contentcontainer_id, + ]); + } + + if ($applied && !$reverted) { + if ($goal instanceof DonationGoal) { + $nextAmount = round((float)$goal->current_amount - (float)$transaction->amount, 2); + $goal->current_amount = max(0.0, $nextAmount); + $goal->save(false); + + $payload['goal_amount_reverted'] = 1; + $payload['goal_amount_reverted_at'] = date('c'); + $payload['goal_amount_reverted_value'] = (float)$transaction->amount; + } + } + + foreach ($metadata as $key => $value) { + $payload[$key] = $value; + } + + $transaction->status = DonationTransaction::STATUS_REFUNDED; + $event = new DonationSettlementEvent(); + $event->transaction = $transaction; + $event->goal = $goal; + $event->payload = $payload; + + Event::trigger(self::class, self::EVENT_AFTER_REFUNDED, $event); + if (is_array($event->payload)) { + $payload = $event->payload; + } + + $transaction->metadata_json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $transaction->save(false); + } + + private function decodeMetadata(?string $json): array + { + $data = json_decode((string)$json, true); + return is_array($data) ? $data : []; + } +} diff --git a/services/ModuleSetupService.php b/services/ModuleSetupService.php new file mode 100644 index 0000000..b1f62bb --- /dev/null +++ b/services/ModuleSetupService.php @@ -0,0 +1,81 @@ + $space->contentcontainer_id]); + if (!$config instanceof DonationProviderConfig) { + $config = new DonationProviderConfig(); + $config->contentcontainer_id = $space->contentcontainer_id; + $config->sandbox_mode = 1; + $config->default_currency = 'USD'; + $config->paypal_enabled = 0; + $config->stripe_enabled = 0; + $config->paypal_recurring_enabled = 0; + $config->stripe_recurring_enabled = 0; + $config->save(false); + $result['providerConfigCreated'] = true; + } else { + $result['providerConfigCreated'] = false; + } + + return $result; + } + + private static function applyModuleMigrations(): array + { + $migrationDir = dirname(__DIR__) . '/migrations'; + $files = glob($migrationDir . '/m*.php') ?: []; + sort($files, SORT_NATURAL); + + $existingVersions = Yii::$app->db->createCommand('SELECT version FROM migration')->queryColumn(); + $history = array_fill_keys($existingVersions, true); + + $applied = []; + $skipped = []; + + foreach ($files as $file) { + $version = pathinfo($file, PATHINFO_FILENAME); + if (isset($history[$version])) { + $skipped[] = $version; + continue; + } + + if (!class_exists($version, false)) { + require_once $file; + } + + if (!class_exists($version, false)) { + throw new \RuntimeException('Migration class not found: ' . $version); + } + + $migration = new $version(); + $ok = method_exists($migration, 'safeUp') ? $migration->safeUp() : $migration->up(); + if ($ok === false) { + throw new \RuntimeException('Migration failed: ' . $version); + } + + Yii::$app->db->createCommand()->insert('migration', [ + 'version' => $version, + 'apply_time' => time(), + ])->execute(); + + $applied[] = $version; + $history[$version] = true; + } + + return [ + 'applied' => $applied, + 'skipped' => $skipped, + ]; + } +} diff --git a/services/providers/PayPalWebhookService.php b/services/providers/PayPalWebhookService.php new file mode 100644 index 0000000..2005036 --- /dev/null +++ b/services/providers/PayPalWebhookService.php @@ -0,0 +1,413 @@ + false, 'status' => 400, 'message' => 'Invalid payload']; + } + + $eventId = (string)$event['id']; + $eventType = (string)$event['event_type']; + + $existing = DonationWebhookEvent::findOne([ + 'provider' => 'paypal', + 'provider_event_id' => $eventId, + ]); + if ($existing instanceof DonationWebhookEvent) { + return ['ok' => true, 'status' => 200, 'message' => 'Already processed']; + } + + $verifiedConfig = $this->resolveVerifiedConfig($event, $headers, $providerConfig); + if (!$verifiedConfig instanceof DonationProviderConfig) { + return ['ok' => false, 'status' => 400, 'message' => 'Signature verification failed']; + } + + $record = new DonationWebhookEvent(); + $record->provider = 'paypal'; + $record->provider_event_id = $eventId; + $record->event_type = $eventType; + $record->payload_json = $payload; + $record->is_processed = 0; + + if (!$record->save()) { + Yii::error($record->getErrors(), 'donations.webhook.paypal.record'); + return ['ok' => false, 'status' => 500, 'message' => 'Could not record webhook event']; + } + + $this->applyEvent($event); + + $record->is_processed = 1; + $record->processed_at = date('Y-m-d H:i:s'); + $record->save(false); + + return ['ok' => true, 'status' => 200, 'message' => 'Processed']; + } + + public function processTestEvent(array $event): array + { + if (empty($event['id']) || empty($event['event_type'])) { + return ['ok' => false, 'status' => 400, 'message' => 'Invalid test event']; + } + + $eventId = (string)$event['id']; + $existing = DonationWebhookEvent::findOne([ + 'provider' => 'paypal', + 'provider_event_id' => $eventId, + ]); + + if ($existing instanceof DonationWebhookEvent) { + return ['ok' => true, 'status' => 200, 'message' => 'Already processed']; + } + + $record = new DonationWebhookEvent(); + $record->provider = 'paypal'; + $record->provider_event_id = $eventId; + $record->event_type = (string)$event['event_type']; + $record->payload_json = json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $record->is_processed = 0; + + if (!$record->save()) { + Yii::error($record->getErrors(), 'donations.webhook.paypal.test'); + return ['ok' => false, 'status' => 500, 'message' => 'Could not record test webhook event']; + } + + $this->applyEvent($event); + + $record->is_processed = 1; + $record->processed_at = date('Y-m-d H:i:s'); + $record->save(false); + + return ['ok' => true, 'status' => 200, 'message' => 'Processed']; + } + + private function resolveVerifiedConfig(array $event, array $headers, ?DonationProviderConfig $providerConfig = null): ?DonationProviderConfig + { + if ($providerConfig instanceof DonationProviderConfig) { + return $this->isValidSignature($event, $headers, $providerConfig) ? $providerConfig : null; + } + + $configs = DonationProviderConfig::find() + ->where(['paypal_enabled' => 1]) + ->all(); + + foreach ($configs as $config) { + if ($this->isValidSignature($event, $headers, $config)) { + return $config; + } + } + + return null; + } + + private function isValidSignature(array $event, array $headers, DonationProviderConfig $providerConfig): bool + { + $webhookId = trim(ProviderCredentialResolver::resolvePayPalWebhookId($providerConfig)); + if ($webhookId === '') { + return false; + } + + $accessToken = $this->requestAccessToken($providerConfig); + if ($accessToken === '') { + return false; + } + + $transmissionId = $this->findHeader($headers, 'Paypal-Transmission-Id'); + $transmissionTime = $this->findHeader($headers, 'Paypal-Transmission-Time'); + $transmissionSig = $this->findHeader($headers, 'Paypal-Transmission-Sig'); + $certUrl = $this->findHeader($headers, 'Paypal-Cert-Url'); + $authAlgo = $this->findHeader($headers, 'Paypal-Auth-Algo'); + + if ($transmissionId === '' || $transmissionTime === '' || $transmissionSig === '' || $certUrl === '' || $authAlgo === '') { + return false; + } + + $baseUrl = ProviderCredentialResolver::isSandboxMode($providerConfig) + ? 'https://api-m.sandbox.paypal.com' + : 'https://api-m.paypal.com'; + + $payload = [ + 'transmission_id' => $transmissionId, + 'transmission_time' => $transmissionTime, + 'cert_url' => $certUrl, + 'auth_algo' => $authAlgo, + 'transmission_sig' => $transmissionSig, + 'webhook_id' => $webhookId, + 'webhook_event' => $event, + ]; + + $response = $this->httpRequest( + 'POST', + $baseUrl . '/v1/notifications/verify-webhook-signature', + [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: application/json', + ], + json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); + + if (!$response['ok']) { + return false; + } + + $data = json_decode((string)$response['body'], true); + return strtoupper((string)($data['verification_status'] ?? '')) === 'SUCCESS'; + } + + private function applyEvent(array $event): void + { + $eventType = (string)($event['event_type'] ?? ''); + $resource = $event['resource'] ?? []; + if (!is_array($resource)) { + return; + } + + if ($eventType === 'PAYMENT.CAPTURE.COMPLETED') { + $transaction = $this->findTransactionForCapture($resource); + if ($transaction instanceof DonationTransaction) { + $transaction->provider_payment_id = (string)($resource['id'] ?? $transaction->provider_payment_id); + $settlement = new DonationSettlementService(); + $settlement->markSucceededAndApply($transaction, [ + 'paypal_capture_completed_at' => date('c'), + ]); + } + return; + } + + if (in_array($eventType, ['PAYMENT.CAPTURE.DENIED', 'PAYMENT.CAPTURE.DECLINED', 'PAYMENT.CAPTURE.FAILED'], true)) { + $transaction = $this->findTransactionForCapture($resource); + if ($transaction instanceof DonationTransaction) { + $transaction->status = DonationTransaction::STATUS_FAILED; + $transaction->provider_payment_id = (string)($resource['id'] ?? $transaction->provider_payment_id); + $transaction->save(false); + } + return; + } + + if ($eventType === 'PAYMENT.CAPTURE.REFUNDED') { + $captureId = (string)($resource['id'] ?? ''); + if ($captureId !== '') { + $transaction = DonationTransaction::findOne(['provider' => 'paypal', 'provider_payment_id' => $captureId]); + if ($transaction instanceof DonationTransaction) { + $settlement = new DonationSettlementService(); + $settlement->markRefundedAndRevert($transaction, [ + 'paypal_capture_refunded_at' => date('c'), + ]); + } + } + return; + } + + if ($eventType === 'BILLING.SUBSCRIPTION.ACTIVATED') { + $subscriptionId = (string)($resource['id'] ?? ''); + if ($subscriptionId === '') { + return; + } + + $transaction = null; + $customId = (int)($resource['custom_id'] ?? 0); + if ($customId > 0) { + $transaction = DonationTransaction::findOne(['id' => $customId, 'provider' => 'paypal']); + } + + if (!$transaction instanceof DonationTransaction) { + $transaction = DonationTransaction::findOne([ + 'provider' => 'paypal', + 'provider_checkout_id' => $subscriptionId, + ]); + } + + $subscription = DonationSubscription::findOne([ + 'provider' => 'paypal', + 'provider_subscription_id' => $subscriptionId, + ]); + + if (!$subscription instanceof DonationSubscription) { + if (!$transaction instanceof DonationTransaction) { + return; + } + + $subscription = new DonationSubscription(); + $subscription->contentcontainer_id = $transaction->contentcontainer_id; + $subscription->donor_user_id = $transaction->donor_user_id; + $subscription->provider = 'paypal'; + $subscription->provider_subscription_id = $subscriptionId; + $subscription->goal_id = $transaction->goal_id; + $subscription->amount = $transaction->amount; + $subscription->currency = $transaction->currency; + $subscription->interval_unit = 'month'; + $subscription->interval_count = 1; + } + + $subscription->status = 'active'; + $subscription->save(false); + + if ($transaction instanceof DonationTransaction) { + $transaction->provider_subscription_id = $subscriptionId; + $transaction->provider_checkout_id = $transaction->provider_checkout_id ?: $subscriptionId; + $settlement = new DonationSettlementService(); + $settlement->markSucceededAndApply($transaction, [ + 'paypal_subscription_activated_at' => date('c'), + ]); + } + return; + } + + if (in_array($eventType, ['BILLING.SUBSCRIPTION.CANCELLED', 'BILLING.SUBSCRIPTION.SUSPENDED'], true)) { + $subscriptionId = (string)($resource['id'] ?? ''); + if ($subscriptionId === '') { + return; + } + + $subscription = DonationSubscription::findOne([ + 'provider' => 'paypal', + 'provider_subscription_id' => $subscriptionId, + ]); + + if ($subscription instanceof DonationSubscription) { + $subscription->status = $eventType === 'BILLING.SUBSCRIPTION.CANCELLED' ? 'cancelled' : 'suspended'; + if ($eventType === 'BILLING.SUBSCRIPTION.CANCELLED') { + $subscription->cancelled_at = date('Y-m-d H:i:s'); + } + $subscription->save(false); + } + } + } + + private function findTransactionForCapture(array $resource): ?DonationTransaction + { + $customId = (int)($resource['custom_id'] ?? 0); + if ($customId > 0) { + $tx = DonationTransaction::findOne(['id' => $customId, 'provider' => 'paypal']); + if ($tx instanceof DonationTransaction) { + return $tx; + } + } + + $orderId = (string)($resource['supplementary_data']['related_ids']['order_id'] ?? ''); + if ($orderId !== '') { + $tx = DonationTransaction::findOne(['provider' => 'paypal', 'provider_checkout_id' => $orderId]); + if ($tx instanceof DonationTransaction) { + return $tx; + } + } + + $captureId = (string)($resource['id'] ?? ''); + if ($captureId !== '') { + $tx = DonationTransaction::findOne(['provider' => 'paypal', 'provider_payment_id' => $captureId]); + if ($tx instanceof DonationTransaction) { + return $tx; + } + } + + return null; + } + + private function appendMetadata(DonationTransaction $transaction, array $data): void + { + $metadata = json_decode((string)$transaction->metadata_json, true); + if (!is_array($metadata)) { + $metadata = []; + } + + foreach ($data as $key => $value) { + $metadata[$key] = $value; + } + + $transaction->metadata_json = json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + private function requestAccessToken(DonationProviderConfig $providerConfig): string + { + $clientId = trim(ProviderCredentialResolver::resolvePayPalClientId($providerConfig)); + $clientSecret = trim(ProviderCredentialResolver::resolvePayPalClientSecret($providerConfig)); + + if ($clientId === '' || $clientSecret === '') { + return ''; + } + + $baseUrl = ProviderCredentialResolver::isSandboxMode($providerConfig) + ? 'https://api-m.sandbox.paypal.com' + : 'https://api-m.paypal.com'; + + $response = $this->httpRequest( + 'POST', + $baseUrl . '/v1/oauth2/token', + [ + 'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret), + 'Content-Type: application/x-www-form-urlencoded', + ], + 'grant_type=client_credentials' + ); + + if (!$response['ok']) { + return ''; + } + + $data = json_decode((string)$response['body'], true); + return (string)($data['access_token'] ?? ''); + } + + private function findHeader(array $headers, string $name): string + { + foreach ($headers as $key => $value) { + if (strcasecmp((string)$key, $name) === 0) { + return trim((string)$value); + } + } + + return ''; + } + + private function httpRequest(string $method, string $url, array $headers, string $body): array + { + if (!function_exists('curl_init')) { + return [ + 'ok' => false, + 'status' => 0, + 'body' => 'cURL extension is not available.', + ]; + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + + if ($body !== '') { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $raw = curl_exec($ch); + $err = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($raw === false) { + return [ + 'ok' => false, + 'status' => $status, + 'body' => $err, + ]; + } + + return [ + 'ok' => $status >= 200 && $status < 300, + 'status' => $status, + 'body' => (string)$raw, + ]; + } +} diff --git a/services/providers/PaymentGatewayService.php b/services/providers/PaymentGatewayService.php new file mode 100644 index 0000000..1f31563 --- /dev/null +++ b/services/providers/PaymentGatewayService.php @@ -0,0 +1,618 @@ +provider === 'stripe') { + return $this->createStripeCheckout($transaction, $goal, $providerConfig, $successUrl, $cancelUrl); + } + + if ($transaction->provider === 'paypal') { + return $this->createPayPalCheckout($transaction, $goal, $providerConfig, $successUrl, $cancelUrl); + } + + return [ + 'success' => false, + 'error' => 'Unsupported payment provider.', + ]; + } + + public function capturePayPalOrder(DonationTransaction $transaction, DonationProviderConfig $providerConfig): array + { + if ($transaction->provider !== 'paypal' || $transaction->mode !== 'one_time') { + return [ + 'success' => false, + 'error' => 'PayPal one-time order capture is only valid for one-time PayPal transactions.', + ]; + } + + $orderId = trim((string)$transaction->provider_checkout_id); + if ($orderId === '') { + return [ + 'success' => false, + 'error' => 'PayPal order ID is missing on transaction.', + ]; + } + + $context = $this->getPayPalRequestContext($providerConfig); + if (!($context['ok'] ?? false)) { + return [ + 'success' => false, + 'error' => (string)($context['error'] ?? 'PayPal auth context is unavailable.'), + 'status' => (int)($context['status'] ?? 0), + 'response' => (string)($context['response'] ?? ''), + ]; + } + + $baseUrl = (string)$context['base_url']; + $accessToken = (string)$context['access_token']; + + $captureResponse = $this->httpRequest( + 'POST', + $baseUrl . '/v2/checkout/orders/' . rawurlencode($orderId) . '/capture', + [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: application/json', + 'Prefer: return=representation', + ], + '{}' + ); + + $captureData = json_decode((string)$captureResponse['body'], true); + $captureId = $this->extractPayPalCaptureId($captureData); + + if ($captureResponse['ok']) { + return [ + 'success' => true, + 'payment_id' => $captureId, + 'raw' => is_array($captureData) ? $captureData : null, + ]; + } + + if ((int)$captureResponse['status'] === 422 && stripos((string)$captureResponse['body'], 'ORDER_ALREADY_CAPTURED') !== false) { + $orderResponse = $this->httpRequest( + 'GET', + $baseUrl . '/v2/checkout/orders/' . rawurlencode($orderId), + [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: application/json', + ], + '' + ); + + $orderData = json_decode((string)$orderResponse['body'], true); + if ($orderResponse['ok']) { + return [ + 'success' => true, + 'already_captured' => true, + 'payment_id' => $this->extractPayPalCaptureId($orderData), + 'raw' => is_array($orderData) ? $orderData : null, + ]; + } + } + + return [ + 'success' => false, + 'error' => 'PayPal order capture failed.', + 'status' => (int)$captureResponse['status'], + 'response' => (string)$captureResponse['body'], + ]; + } + + private function createStripeCheckout( + DonationTransaction $transaction, + DonationGoal $goal, + DonationProviderConfig $providerConfig, + string $successUrl, + string $cancelUrl + ): array { + $secretKey = trim(ProviderCredentialResolver::resolveStripeSecretKey($providerConfig)); + if ($secretKey === '') { + return [ + 'success' => false, + 'error' => 'Stripe secret key is missing.', + ]; + } + + $currency = strtolower((string)$transaction->currency); + $unitAmount = (int)round(((float)$transaction->amount) * 100); + if ($unitAmount < 1) { + return [ + 'success' => false, + 'error' => 'Invalid donation amount.', + ]; + } + + $lineItem = [ + 'line_items[0][quantity]' => 1, + 'line_items[0][price_data][currency]' => $currency, + 'line_items[0][price_data][product_data][name]' => (string)$goal->title, + 'line_items[0][price_data][unit_amount]' => $unitAmount, + ]; + + if ($transaction->mode === 'recurring') { + $payload = array_merge($lineItem, [ + 'mode' => 'subscription', + 'line_items[0][price_data][recurring][interval]' => 'month', + ]); + } else { + $payload = array_merge($lineItem, [ + 'mode' => 'payment', + ]); + } + + $payload['success_url'] = $successUrl; + $payload['cancel_url'] = $cancelUrl; + $payload['metadata[transaction_id]'] = (string)$transaction->id; + $payload['metadata[goal_id]'] = (string)$transaction->goal_id; + $payload['metadata[contentcontainer_id]'] = (string)$transaction->contentcontainer_id; + + $response = $this->httpRequest( + 'POST', + 'https://api.stripe.com/v1/checkout/sessions', + [ + 'Authorization: Bearer ' . $secretKey, + 'Content-Type: application/x-www-form-urlencoded', + ], + http_build_query($payload, '', '&') + ); + + if (!$response['ok']) { + return [ + 'success' => false, + 'error' => 'Stripe checkout session creation failed.', + 'status' => $response['status'], + 'response' => $response['body'], + ]; + } + + $data = json_decode((string)$response['body'], true); + if (!is_array($data) || empty($data['url']) || empty($data['id'])) { + return [ + 'success' => false, + 'error' => 'Stripe response is invalid.', + 'response' => $response['body'], + ]; + } + + return [ + 'success' => true, + 'redirect_url' => (string)$data['url'], + 'checkout_id' => (string)$data['id'], + 'payment_id' => (string)($data['payment_intent'] ?? ''), + 'subscription_id' => (string)($data['subscription'] ?? ''), + 'customer_id' => (string)($data['customer'] ?? ''), + 'raw' => $data, + ]; + } + + private function createPayPalCheckout( + DonationTransaction $transaction, + DonationGoal $goal, + DonationProviderConfig $providerConfig, + string $successUrl, + string $cancelUrl + ): array { + $clientId = trim(ProviderCredentialResolver::resolvePayPalClientId($providerConfig)); + $clientSecret = trim(ProviderCredentialResolver::resolvePayPalClientSecret($providerConfig)); + + if ($clientId === '' || $clientSecret === '') { + return [ + 'success' => false, + 'error' => 'PayPal client credentials are missing.', + ]; + } + + $baseUrl = ProviderCredentialResolver::isSandboxMode($providerConfig) + ? 'https://api-m.sandbox.paypal.com' + : 'https://api-m.paypal.com'; + + $tokenResponse = $this->httpRequest( + 'POST', + $baseUrl . '/v1/oauth2/token', + [ + 'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret), + 'Content-Type: application/x-www-form-urlencoded', + ], + 'grant_type=client_credentials' + ); + + if (!$tokenResponse['ok']) { + return [ + 'success' => false, + 'error' => 'PayPal token request failed.', + 'status' => $tokenResponse['status'], + 'response' => $tokenResponse['body'], + ]; + } + + $accessToken = $this->extractAccessToken($tokenResponse['body']); + if ($accessToken === '') { + return [ + 'success' => false, + 'error' => 'PayPal access token missing in response.', + ]; + } + + if ($transaction->mode === 'recurring') { + return $this->createPayPalRecurringCheckout( + $transaction, + $goal, + $providerConfig, + $baseUrl, + $accessToken, + $successUrl, + $cancelUrl + ); + } + + $orderPayload = [ + 'intent' => 'CAPTURE', + 'purchase_units' => [[ + 'custom_id' => (string)$transaction->id, + 'description' => (string)$goal->title, + 'amount' => [ + 'currency_code' => strtoupper((string)$transaction->currency), + 'value' => number_format((float)$transaction->amount, 2, '.', ''), + ], + ]], + 'application_context' => [ + 'return_url' => $successUrl, + 'cancel_url' => $cancelUrl, + ], + ]; + + $orderResponse = $this->httpRequest( + 'POST', + $baseUrl . '/v2/checkout/orders', + [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: application/json', + ], + json_encode($orderPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); + + if (!$orderResponse['ok']) { + return [ + 'success' => false, + 'error' => 'PayPal order creation failed.', + 'status' => $orderResponse['status'], + 'response' => $orderResponse['body'], + ]; + } + + $orderData = json_decode((string)$orderResponse['body'], true); + if (!is_array($orderData) || empty($orderData['id'])) { + return [ + 'success' => false, + 'error' => 'PayPal response is invalid.', + 'response' => $orderResponse['body'], + ]; + } + + $approveUrl = ''; + foreach ((array)($orderData['links'] ?? []) as $link) { + if (($link['rel'] ?? '') === 'approve') { + $approveUrl = (string)($link['href'] ?? ''); + break; + } + } + + if ($approveUrl === '') { + return [ + 'success' => false, + 'error' => 'PayPal approve URL is missing.', + 'response' => $orderResponse['body'], + ]; + } + + return [ + 'success' => true, + 'redirect_url' => $approveUrl, + 'checkout_id' => (string)$orderData['id'], + 'raw' => $orderData, + ]; + } + + private function createPayPalRecurringCheckout( + DonationTransaction $transaction, + DonationGoal $goal, + DonationProviderConfig $providerConfig, + string $baseUrl, + string $accessToken, + string $successUrl, + string $cancelUrl + ): array { + $currency = strtoupper((string)$transaction->currency); + $amount = number_format((float)$transaction->amount, 2, '.', ''); + + $productPayload = [ + 'name' => substr((string)$goal->title, 0, 127), + 'description' => substr((string)($goal->description ?: $goal->title), 0, 255), + 'type' => 'SERVICE', + 'category' => 'SOFTWARE', + ]; + + $productResponse = $this->httpRequest( + 'POST', + $baseUrl . '/v1/catalogs/products', + [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: application/json', + ], + json_encode($productPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); + + if (!$productResponse['ok']) { + return [ + 'success' => false, + 'error' => 'PayPal product creation failed.', + 'status' => $productResponse['status'], + 'response' => $productResponse['body'], + ]; + } + + $productData = json_decode((string)$productResponse['body'], true); + $productId = (string)($productData['id'] ?? ''); + if ($productId === '') { + return [ + 'success' => false, + 'error' => 'PayPal product response is invalid.', + 'response' => $productResponse['body'], + ]; + } + + $planPayload = [ + 'product_id' => $productId, + 'name' => substr((string)$goal->title . ' Monthly Donation', 0, 127), + 'status' => 'ACTIVE', + 'billing_cycles' => [[ + 'frequency' => [ + 'interval_unit' => 'MONTH', + 'interval_count' => 1, + ], + 'tenure_type' => 'REGULAR', + 'sequence' => 1, + 'total_cycles' => 0, + 'pricing_scheme' => [ + 'fixed_price' => [ + 'value' => $amount, + 'currency_code' => $currency, + ], + ], + ]], + 'payment_preferences' => [ + 'auto_bill_outstanding' => true, + 'setup_fee_failure_action' => 'CONTINUE', + 'payment_failure_threshold' => 3, + ], + ]; + + $planResponse = $this->httpRequest( + 'POST', + $baseUrl . '/v1/billing/plans', + [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: application/json', + ], + json_encode($planPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); + + if (!$planResponse['ok']) { + return [ + 'success' => false, + 'error' => 'PayPal plan creation failed.', + 'status' => $planResponse['status'], + 'response' => $planResponse['body'], + ]; + } + + $planData = json_decode((string)$planResponse['body'], true); + $planId = (string)($planData['id'] ?? ''); + if ($planId === '') { + return [ + 'success' => false, + 'error' => 'PayPal plan response is invalid.', + 'response' => $planResponse['body'], + ]; + } + + $subscriptionPayload = [ + 'plan_id' => $planId, + 'custom_id' => (string)$transaction->id, + 'application_context' => [ + 'brand_name' => 'Rescue Hub', + 'user_action' => 'SUBSCRIBE_NOW', + 'return_url' => $successUrl, + 'cancel_url' => $cancelUrl, + ], + ]; + + $subscriptionResponse = $this->httpRequest( + 'POST', + $baseUrl . '/v1/billing/subscriptions', + [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: application/json', + ], + json_encode($subscriptionPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); + + if (!$subscriptionResponse['ok']) { + return [ + 'success' => false, + 'error' => 'PayPal subscription creation failed.', + 'status' => $subscriptionResponse['status'], + 'response' => $subscriptionResponse['body'], + ]; + } + + $subscriptionData = json_decode((string)$subscriptionResponse['body'], true); + $subscriptionId = (string)($subscriptionData['id'] ?? ''); + if ($subscriptionId === '') { + return [ + 'success' => false, + 'error' => 'PayPal subscription response is invalid.', + 'response' => $subscriptionResponse['body'], + ]; + } + + $approveUrl = ''; + foreach ((array)($subscriptionData['links'] ?? []) as $link) { + if (($link['rel'] ?? '') === 'approve') { + $approveUrl = (string)($link['href'] ?? ''); + break; + } + } + + if ($approveUrl === '') { + return [ + 'success' => false, + 'error' => 'PayPal subscription approve URL is missing.', + 'response' => $subscriptionResponse['body'], + ]; + } + + return [ + 'success' => true, + 'redirect_url' => $approveUrl, + 'checkout_id' => $subscriptionId, + 'subscription_id' => $subscriptionId, + 'raw' => $subscriptionData, + ]; + } + + private function extractAccessToken(string $responseBody): string + { + $data = json_decode($responseBody, true); + return (string)($data['access_token'] ?? ''); + } + + private function getPayPalRequestContext(DonationProviderConfig $providerConfig): array + { + $clientId = trim(ProviderCredentialResolver::resolvePayPalClientId($providerConfig)); + $clientSecret = trim(ProviderCredentialResolver::resolvePayPalClientSecret($providerConfig)); + + if ($clientId === '' || $clientSecret === '') { + return [ + 'ok' => false, + 'error' => 'PayPal client credentials are missing.', + 'status' => 0, + 'response' => '', + ]; + } + + $baseUrl = ProviderCredentialResolver::isSandboxMode($providerConfig) + ? 'https://api-m.sandbox.paypal.com' + : 'https://api-m.paypal.com'; + + $tokenResponse = $this->httpRequest( + 'POST', + $baseUrl . '/v1/oauth2/token', + [ + 'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret), + 'Content-Type: application/x-www-form-urlencoded', + ], + 'grant_type=client_credentials' + ); + + if (!$tokenResponse['ok']) { + return [ + 'ok' => false, + 'error' => 'PayPal token request failed.', + 'status' => (int)$tokenResponse['status'], + 'response' => (string)$tokenResponse['body'], + ]; + } + + $accessToken = $this->extractAccessToken((string)$tokenResponse['body']); + if ($accessToken === '') { + return [ + 'ok' => false, + 'error' => 'PayPal access token missing in response.', + 'status' => 0, + 'response' => (string)$tokenResponse['body'], + ]; + } + + return [ + 'ok' => true, + 'base_url' => $baseUrl, + 'access_token' => $accessToken, + ]; + } + + private function extractPayPalCaptureId($orderData): string + { + if (!is_array($orderData)) { + return ''; + } + + $units = (array)($orderData['purchase_units'] ?? []); + $firstUnit = $units[0] ?? null; + if (!is_array($firstUnit)) { + return ''; + } + + $captures = (array)($firstUnit['payments']['captures'] ?? []); + $firstCapture = $captures[0] ?? null; + if (!is_array($firstCapture)) { + return ''; + } + + return (string)($firstCapture['id'] ?? ''); + } + + private function httpRequest(string $method, string $url, array $headers, string $body): array + { + if (!function_exists('curl_init')) { + return [ + 'ok' => false, + 'status' => 0, + 'body' => 'cURL extension is not available.', + ]; + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + + if ($body !== '') { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $raw = curl_exec($ch); + $err = curl_error($ch); + $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($raw === false) { + return [ + 'ok' => false, + 'status' => $status, + 'body' => $err, + ]; + } + + return [ + 'ok' => $status >= 200 && $status < 300, + 'status' => $status, + 'body' => (string)$raw, + ]; + } +} diff --git a/services/providers/ProviderCredentialResolver.php b/services/providers/ProviderCredentialResolver.php new file mode 100644 index 0000000..ad2e145 --- /dev/null +++ b/services/providers/ProviderCredentialResolver.php @@ -0,0 +1,134 @@ +get('sandbox_mode', $config->sandbox_mode ?? 1) + : ($config->sandbox_mode ?? 1); + + return (int)$value === 1; + } + + public static function resolvePayPalClientId(DonationProviderConfig $config): string + { + return self::resolveModeValue( + $config, + 'paypal_sandbox_client_id', + 'paypal_live_client_id', + (string)$config->paypal_client_id + ); + } + + public static function resolvePayPalClientSecret(DonationProviderConfig $config): string + { + return self::resolveModeValue( + $config, + 'paypal_sandbox_client_secret', + 'paypal_live_client_secret', + (string)$config->paypal_client_secret + ); + } + + public static function resolvePayPalWebhookId(DonationProviderConfig $config): string + { + return self::resolveModeValue( + $config, + 'paypal_sandbox_webhook_id', + 'paypal_live_webhook_id', + (string)$config->paypal_webhook_id + ); + } + + public static function resolveStripePublishableKey(DonationProviderConfig $config): string + { + return self::resolveModeValue( + $config, + 'stripe_sandbox_publishable_key', + 'stripe_live_publishable_key', + (string)$config->stripe_publishable_key + ); + } + + public static function resolveStripeSecretKey(DonationProviderConfig $config): string + { + return self::resolveModeValue( + $config, + 'stripe_sandbox_secret_key', + 'stripe_live_secret_key', + (string)$config->stripe_secret_key + ); + } + + public static function resolveStripeWebhookSecret(DonationProviderConfig $config): string + { + return self::resolveModeValue( + $config, + 'stripe_sandbox_webhook_secret', + 'stripe_live_webhook_secret', + (string)$config->stripe_webhook_secret + ); + } + + private static function resolveModeValue( + DonationProviderConfig $config, + string $sandboxKey, + string $liveKey, + string $legacyFallback + ): string { + $settings = self::settingsForConfig($config); + $isSandbox = self::isSandboxMode($config); + + if ($settings === null) { + return trim($legacyFallback); + } + + $sandboxValue = trim((string)$settings->get($sandboxKey, '')); + $liveValue = trim((string)$settings->get($liveKey, '')); + $legacyValue = trim((string)$settings->get(self::legacyKeyFor($sandboxKey, $liveKey), $legacyFallback)); + + if ($isSandbox) { + return $sandboxValue !== '' ? $sandboxValue : $legacyValue; + } + + return $liveValue !== '' ? $liveValue : $legacyValue; + } + + private static function legacyKeyFor(string $sandboxKey, string $liveKey): string + { + $map = [ + 'paypal_sandbox_client_id' => 'paypal_client_id', + 'paypal_live_client_id' => 'paypal_client_id', + 'paypal_sandbox_client_secret' => 'paypal_client_secret', + 'paypal_live_client_secret' => 'paypal_client_secret', + 'paypal_sandbox_webhook_id' => 'paypal_webhook_id', + 'paypal_live_webhook_id' => 'paypal_webhook_id', + 'stripe_sandbox_publishable_key' => 'stripe_publishable_key', + 'stripe_live_publishable_key' => 'stripe_publishable_key', + 'stripe_sandbox_secret_key' => 'stripe_secret_key', + 'stripe_live_secret_key' => 'stripe_secret_key', + 'stripe_sandbox_webhook_secret' => 'stripe_webhook_secret', + 'stripe_live_webhook_secret' => 'stripe_webhook_secret', + ]; + + return $map[$sandboxKey] ?? ($map[$liveKey] ?? ''); + } + + private static function settingsForConfig(DonationProviderConfig $config) + { + $space = Space::findOne(['contentcontainer_id' => (int)$config->contentcontainer_id]); + if (!$space instanceof Space) { + return null; + } + + return Yii::$app->getModule('donations')->settings->contentContainer($space); + } +} diff --git a/services/providers/StripeWebhookService.php b/services/providers/StripeWebhookService.php new file mode 100644 index 0000000..9078abf --- /dev/null +++ b/services/providers/StripeWebhookService.php @@ -0,0 +1,235 @@ + false, 'status' => 400, 'message' => 'Invalid payload']; + } + + $eventId = (string)$event['id']; + $eventType = (string)$event['type']; + + $existing = DonationWebhookEvent::findOne([ + 'provider' => 'stripe', + 'provider_event_id' => $eventId, + ]); + + if ($existing instanceof DonationWebhookEvent) { + return ['ok' => true, 'status' => 200, 'message' => 'Already processed']; + } + + if (!$this->verifySignatureForConfig($payload, $signatureHeader, $providerConfig)) { + return ['ok' => false, 'status' => 400, 'message' => 'Signature verification failed']; + } + + $record = new DonationWebhookEvent(); + $record->provider = 'stripe'; + $record->provider_event_id = $eventId; + $record->event_type = $eventType; + $record->payload_json = $payload; + $record->is_processed = 0; + + if (!$record->save()) { + Yii::error($record->getErrors(), 'donations.webhook.stripe.record'); + return ['ok' => false, 'status' => 500, 'message' => 'Could not record webhook event']; + } + + $this->applyEvent($event); + + $record->is_processed = 1; + $record->processed_at = date('Y-m-d H:i:s'); + $record->save(false); + + return ['ok' => true, 'status' => 200, 'message' => 'Processed']; + } + + private function applyEvent(array $event): void + { + $type = (string)($event['type'] ?? ''); + $object = $event['data']['object'] ?? []; + if (!is_array($object)) { + return; + } + + if ($type === 'checkout.session.completed') { + $this->handleCheckoutCompleted($object); + return; + } + + if ($type === 'checkout.session.expired') { + $sessionId = (string)($object['id'] ?? ''); + if ($sessionId !== '') { + $transaction = DonationTransaction::findOne(['provider_checkout_id' => $sessionId, 'provider' => 'stripe']); + if ($transaction instanceof DonationTransaction && $transaction->status === DonationTransaction::STATUS_PENDING) { + $transaction->status = DonationTransaction::STATUS_CANCELLED; + $transaction->save(false); + } + } + return; + } + + if ($type === 'payment_intent.payment_failed') { + $paymentIntentId = (string)($object['id'] ?? ''); + if ($paymentIntentId !== '') { + $transaction = DonationTransaction::findOne(['provider_payment_id' => $paymentIntentId, 'provider' => 'stripe']); + if ($transaction instanceof DonationTransaction) { + $transaction->status = DonationTransaction::STATUS_FAILED; + $transaction->save(false); + } + } + return; + } + + if ($type === 'charge.refunded') { + $paymentIntentId = (string)($object['payment_intent'] ?? ''); + if ($paymentIntentId !== '') { + $transaction = DonationTransaction::findOne(['provider_payment_id' => $paymentIntentId, 'provider' => 'stripe']); + if ($transaction instanceof DonationTransaction) { + $settlement = new DonationSettlementService(); + $settlement->markRefundedAndRevert($transaction, [ + 'stripe_refunded_at' => date('c'), + ]); + } + } + return; + } + + if ($type === 'customer.subscription.deleted') { + $subscriptionId = (string)($object['id'] ?? ''); + if ($subscriptionId !== '') { + $subscription = DonationSubscription::findOne(['provider' => 'stripe', 'provider_subscription_id' => $subscriptionId]); + if ($subscription instanceof DonationSubscription) { + $subscription->status = 'cancelled'; + $subscription->cancelled_at = date('Y-m-d H:i:s'); + $subscription->save(false); + } + } + } + } + + private function handleCheckoutCompleted(array $session): void + { + $metadata = (array)($session['metadata'] ?? []); + $transactionId = (int)($metadata['transaction_id'] ?? 0); + + $transaction = null; + if ($transactionId > 0) { + $transaction = DonationTransaction::findOne(['id' => $transactionId, 'provider' => 'stripe']); + } + + if (!$transaction instanceof DonationTransaction) { + $sessionId = (string)($session['id'] ?? ''); + if ($sessionId !== '') { + $transaction = DonationTransaction::findOne(['provider_checkout_id' => $sessionId, 'provider' => 'stripe']); + } + } + + if (!$transaction instanceof DonationTransaction) { + return; + } + + $transaction->provider_checkout_id = (string)($session['id'] ?? $transaction->provider_checkout_id); + $transaction->provider_payment_id = (string)($session['payment_intent'] ?? $transaction->provider_payment_id); + $transaction->provider_subscription_id = (string)($session['subscription'] ?? $transaction->provider_subscription_id); + $transaction->provider_customer_id = (string)($session['customer'] ?? $transaction->provider_customer_id); + $settlement = new DonationSettlementService(); + $settlement->markSucceededAndApply($transaction, [ + 'stripe_checkout_completed_at' => date('c'), + ]); + + if ($transaction->mode === 'recurring' && !empty($transaction->provider_subscription_id)) { + $subscription = DonationSubscription::findOne([ + 'provider' => 'stripe', + 'provider_subscription_id' => $transaction->provider_subscription_id, + ]); + + if (!$subscription instanceof DonationSubscription) { + $subscription = new DonationSubscription(); + $subscription->contentcontainer_id = $transaction->contentcontainer_id; + $subscription->donor_user_id = $transaction->donor_user_id; + $subscription->provider = 'stripe'; + $subscription->provider_subscription_id = $transaction->provider_subscription_id; + $subscription->goal_id = $transaction->goal_id; + } + + $subscription->status = 'active'; + $subscription->amount = $transaction->amount; + $subscription->currency = $transaction->currency; + $subscription->interval_unit = 'month'; + $subscription->interval_count = 1; + $subscription->save(false); + } + } + + private function verifySignatureForConfig(string $payload, string $signatureHeader, ?DonationProviderConfig $providerConfig = null): bool + { + if ($providerConfig instanceof DonationProviderConfig) { + $secret = trim(ProviderCredentialResolver::resolveStripeWebhookSecret($providerConfig)); + return $secret !== '' && $this->verifySignature($payload, $signatureHeader, $secret); + } + + $configs = DonationProviderConfig::find() + ->where(['stripe_enabled' => 1]) + ->andWhere(['not', ['stripe_webhook_secret' => null]]) + ->all(); + + foreach ($configs as $config) { + $secret = trim(ProviderCredentialResolver::resolveStripeWebhookSecret($config)); + if ($secret !== '' && $this->verifySignature($payload, $signatureHeader, $secret)) { + return true; + } + } + + return false; + } + + private function verifySignature(string $payload, string $signatureHeader, string $secret): bool + { + if ($signatureHeader === '') { + return false; + } + + $parts = []; + foreach (explode(',', $signatureHeader) as $segment) { + $kv = explode('=', trim($segment), 2); + if (count($kv) === 2) { + $parts[$kv[0]][] = $kv[1]; + } + } + + $timestamp = $parts['t'][0] ?? null; + $signatures = $parts['v1'] ?? []; + + if ($timestamp === null || empty($signatures)) { + return false; + } + + $signedPayload = $timestamp . '.' . $payload; + $expected = hash_hmac('sha256', $signedPayload, $secret); + + $ts = (int)$timestamp; + if ($ts > 0 && abs(time() - $ts) > 600) { + return false; + } + + foreach ($signatures as $signature) { + if (hash_equals($expected, (string)$signature)) { + return true; + } + } + + return false; + } +} diff --git a/views/donations/index.php b/views/donations/index.php new file mode 100644 index 0000000..3ef8aff --- /dev/null +++ b/views/donations/index.php @@ -0,0 +1,341 @@ + 0.0, 'donated' => 0.0, 'percent' => 0.0]; +$ytd = is_array($dashboardData['ytd'] ?? null) ? $dashboardData['ytd'] : ['year' => (int)date('Y'), 'donated' => 0.0, 'target' => 0.0, 'percent' => 0.0]; +$previousYear = is_array($dashboardData['previousYear'] ?? null) ? $dashboardData['previousYear'] : null; + +$layout = trim((string)Yii::$app->request->get('layout', 'tiles')); +if (in_array($layout, ['cols2', 'cols3'], true)) { + $layout = 'tiles'; +} +if (!in_array($layout, ['tiles', 'table'], true)) { + $layout = 'tiles'; +} + +$modeOptions = [ + 'one_time' => Yii::t('DonationsModule.base', 'One-time'), +]; +if (!empty($recurringOptions)) { + $modeOptions['recurring'] = Yii::t('DonationsModule.base', 'Recurring'); +} + +$buildPageUrl = static function (array $overrides) use ($space, $selectedMonth, $layout): string { + $params = array_merge([ + 'month' => $selectedMonth, + 'layout' => $layout, + ], $overrides); + return $space->createUrl('/donations/donations/index', $params); +}; + +$selectedMonthIndex = array_search($selectedMonth, $months, true); +$prevMonth = ($selectedMonthIndex !== false && isset($months[$selectedMonthIndex + 1])) ? (string)$months[$selectedMonthIndex + 1] : ''; +$nextMonth = ($selectedMonthIndex !== false && $selectedMonthIndex > 0 && isset($months[$selectedMonthIndex - 1])) ? (string)$months[$selectedMonthIndex - 1] : ''; + +$userGridClass = 'col-md-6 col-sm-6 col-xs-12'; +$hasUserDashboard = $schemaReady && !Yii::$app->user->isGuest; +?> + + + +
+
+ My Donations') ?> + + ', $buildPageUrl(['layout' => 'tiles']), ['class' => 'btn btn-default btn-xs donations-view-toggle' . ($layout === 'tiles' ? ' active' : ''), 'title' => Yii::t('DonationsModule.base', 'Tile View'), 'aria-label' => Yii::t('DonationsModule.base', 'Tile View')]) ?> + ', $buildPageUrl(['layout' => 'table']), ['class' => 'btn btn-default btn-xs donations-view-toggle' . ($layout === 'table' ? ' active' : ''), 'title' => Yii::t('DonationsModule.base', 'Table View'), 'aria-label' => Yii::t('DonationsModule.base', 'Table View')]) ?> + +
+
+ +
+ +
+ + + user->isGuest): ?> +
+ +
+ + + +
+ +
+
+ : + $ +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + $$$
+
+ +
+ + 0 ? min(100.0, ($goalCurrent / $goalTarget) * 100.0) : 0.0; + $annualTotals = (array)($row['annual'] ?? []); + krsort($annualTotals, SORT_NUMERIC); + ?> +
+
+ + <?= Html::encode((string)$row['animalName']) ?> + +
+ +
+ +
+
+ + + + + +
+
+ : + $ +
+ +
+
+
+
+ : + $ / $ + (%) +
+ +
+
+ +
+ + $ +
+ +
+ +
+ $amount): ?> +
+ + $ +
+ +
+
+
+ +
+ +
+ + +
+
+
+ + + + ', $buildPageUrl(['month' => $prevMonth]), ['class' => 'btn btn-default btn-xs', 'title' => Yii::t('DonationsModule.base', 'Previous Month')]) ?> + + + + ', $buildPageUrl(['month' => $nextMonth]), ['class' => 'btn btn-default btn-xs', 'title' => Yii::t('DonationsModule.base', 'Next Month')]) ?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
$$%
+ + + + + + $$%
+
+
+
+
+
+
:
+
: $
+
: $
+
: %
+
+
+ + +
+
()
+
+
: $
+
: $
+
: %
+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/views/settings/history.php b/views/settings/history.php new file mode 100644 index 0000000..7db685b --- /dev/null +++ b/views/settings/history.php @@ -0,0 +1,146 @@ + + +
+
Donations History') ?>
+ + + + + +
+
+ createUrl('/donations/settings'), + ['class' => 'btn btn-default btn-sm'] + ) ?> + + createUrl('/donations/settings/reconcile-pending'), 'post', ['style' => 'display:inline-block;margin-left:8px;']) ?> + 'btn btn-primary btn-sm', + 'data-confirm' => Yii::t('DonationsModule.base', 'Attempt reconciliation for pending transactions in this space now?'), + ] + ) ?> + +
+ +

+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID
id ?>provider) ?>mode) ?>status) ?>amount, 2) ?> currency) ?>#goal_id ?>provider_checkout_id) ?>provider_payment_id) ?>created_at) ?>
+
+ + +

+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ID
id ?>provider) ?>status) ?>amount, 2) ?> currency) ?>interval_count) ?> interval_unit) ?>provider_subscription_id) ?>created_at) ?>
+
+ + +

+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
ID
id ?>provider) ?>event_type) ?>provider_event_id) ?>is_processed === 1 ? Yii::t('DonationsModule.base', 'Yes') : Yii::t('DonationsModule.base', 'No') ?>created_at) ?>
+
+ +
+
diff --git a/views/settings/index.php b/views/settings/index.php new file mode 100644 index 0000000..4dd335d --- /dev/null +++ b/views/settings/index.php @@ -0,0 +1,635 @@ +id); +$showAnimalTargetField = $goalForm->goal_type === DonationGoal::TYPE_ANIMAL; +$hostInfo = Yii::$app->request->hostInfo; +$stripeWebhookUrl = $hostInfo . $providerForm->contentContainer->createUrl('/donations/donations/stripe-webhook'); +$paypalWebhookUrl = $hostInfo . $providerForm->contentContainer->createUrl('/donations/donations/paypal-webhook'); + +$tabs = [ + 'general' => Yii::t('DonationsModule.base', 'General'), + 'goals' => Yii::t('DonationsModule.base', 'Goals'), + 'payment-providers' => Yii::t('DonationsModule.base', 'Payment Providers'), + 'donation-history' => Yii::t('DonationsModule.base', 'Donation History'), + 'advanced' => Yii::t('DonationsModule.base', 'Advanced'), +]; + +if (!array_key_exists($activeTab, $tabs)) { + $activeTab = 'general'; +} + +$tabUrl = static function (string $tab) use ($providerForm): string { + return $providerForm->contentContainer->createUrl('/donations/settings', ['tab' => $tab]); +}; +?> + +
+
Donations Settings') ?>
+ + + + + +
+ +
+ +
+ + + + + +
+
+
+ +
+ +
+ + + + errorSummary($providerForm, ['class' => 'alert alert-danger']) ?> + +
+
+ field($providerForm, 'default_currency')->textInput(['maxlength' => 8]) ?> +
+
+ field($providerForm, 'animal_tile_extra_height_px')->input('number', [ + 'min' => 0, + 'max' => 600, + 'step' => 1, + ])->hint(Yii::t('DonationsModule.base', 'Adds extra height to animal cards in pixels to accommodate donation overlays.')) ?> +
+
+ field($providerForm, 'animal_donation_form_header')->textInput(['maxlength' => 255]) + ->hint(Yii::t('DonationsModule.base', 'Use [animal-name] to insert the animal name, e.g. "Your Donation directly supports [animal-name]".')) ?> +
+
+ + 'btn btn-primary']) ?> + + +
+
+ + + +
+
+
+ +
+ +
+ + +
+ + 'btn btn-xs btn-default', 'style' => 'margin-left:8px;'] + ) ?> +
+ + + ['enctype' => 'multipart/form-data'], + ]); ?> + errorSummary($goalForm, ['class' => 'alert alert-danger']) ?> + field($goalForm, 'id')->hiddenInput()->label(false) ?> + +
+
field($goalForm, 'goal_type')->dropDownList($goalTypeOptions) ?>
+
+ field($goalForm, 'target_animal_id')->dropDownList($animalOptions, ['prompt' => Yii::t('DonationsModule.base', 'Select animal')]) ?> +
+
field($goalForm, 'title') ?>
+
field($goalForm, 'target_amount')->input('number', ['step' => '0.01', 'min' => '0']) ?>
+
+ + field($goalForm, 'description')->textarea(['rows' => 3]) ?> + +
+
+
+ field($goalForm, 'imageGalleryPath')->hiddenInput()->label(false) ?> +
+ + + + field($goalForm, 'imageFile')->fileInput(['accept' => 'image/*']) ?> +
+
+ + field($goalForm, 'is_active')->checkbox() ?> + + 'btn btn-primary'] + ) ?> + + +
+
+ +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
title) ?>goal_type] ?? $goal->goal_type) ?>current_amount, 2) ?> / target_amount, 2) ?> currency) ?> + is_active === 1): ?> + + + + + + contentContainer->createUrl('/donations/settings', [ + 'tab' => 'goals', + 'goalId' => (int)$goal->id, + ]), + ['class' => 'btn btn-xs btn-primary'] + ) ?> + contentContainer->createUrl('/donations/settings/delete-goal', ['id' => (int)$goal->id]), + [ + 'class' => 'btn btn-xs btn-danger', + 'data-method' => 'post', + 'data-confirm' => Yii::t('DonationsModule.base', 'Delete this donation goal?'), + ] + ) ?> +
+
+ +
+
+ + + +
+
+
+ +
+ +
+ + + + errorSummary($providerForm, ['class' => 'alert alert-danger']) ?> + +
+ +
+ +
+
field($providerForm, 'sandbox_mode')->checkbox() ?>
+
+ +
+
+
+
+
+
+
field($providerForm, 'paypal_enabled')->checkbox() ?>
+
field($providerForm, 'paypal_recurring_enabled')->checkbox() ?>
+
+ +
+
+
+ field($providerForm, 'paypal_sandbox_client_id') ?> + field($providerForm, 'paypal_sandbox_client_secret') ?> + field($providerForm, 'paypal_sandbox_webhook_id') ?> +
+
+ +
+
+
+ field($providerForm, 'paypal_live_client_id') ?> + field($providerForm, 'paypal_live_client_secret') ?> + field($providerForm, 'paypal_live_webhook_id') ?> +
+
+
+
+
+ +
+
+
+
+
+
field($providerForm, 'stripe_enabled')->checkbox() ?>
+
field($providerForm, 'stripe_recurring_enabled')->checkbox() ?>
+
+ +
+
+
+ field($providerForm, 'stripe_sandbox_publishable_key') ?> + field($providerForm, 'stripe_sandbox_secret_key') ?> + field($providerForm, 'stripe_sandbox_webhook_secret') ?> +
+
+ +
+
+
+ field($providerForm, 'stripe_live_publishable_key') ?> + field($providerForm, 'stripe_live_secret_key') ?> + field($providerForm, 'stripe_live_webhook_secret') ?> +
+
+
+
+
+
+ + 'btn btn-primary']) ?> + + +
+
+ + + +
+
+
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID
id ?>provider) ?>mode) ?>status) ?>amount, 2) ?> currency) ?>#goal_id ?>provider_checkout_id) ?>provider_payment_id) ?>created_at) ?>
+
+ +
+
+ +
+
+
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ID
id ?>provider) ?>status) ?>amount, 2) ?> currency) ?>interval_count) ?> interval_unit) ?>provider_subscription_id) ?>created_at) ?>
+
+ +
+
+ +
+
+
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
ID
id ?>provider) ?>event_type) ?>provider_event_id) ?>is_processed === 1 ? Yii::t('DonationsModule.base', 'Yes') : Yii::t('DonationsModule.base', 'No') ?>created_at) ?>
+
+ +
+
+ + + +
+
+
+ +
+
+ +
+ contentContainer->createUrl('/donations/settings/setup'), + [ + 'class' => 'btn btn-primary btn-sm', + 'data-method' => 'post', + 'data-confirm' => Yii::t('DonationsModule.base', 'Run Donations setup now for this space?'), + ] + ) ?> +
+ + +
+ +
+ +
+
:
+
:
+
+ +
+
+
+

+ +

+
+ contentContainer->createUrl('/donations/settings/simulate-stripe-webhook'), 'post', ['style' => 'display:inline-block;']) ?> + 'btn btn-default btn-sm']) ?> + + + contentContainer->createUrl('/donations/settings/simulate-paypal-webhook'), 'post', ['style' => 'display:inline-block;']) ?> + 'btn btn-default btn-sm']) ?> + +
+
+
+ +
+ contentContainer->createUrl('/donations/settings/reconcile-pending'), 'post', ['style' => 'display:inline-block;margin-bottom:0;']) ?> + 'btn btn-primary btn-sm', + 'data-confirm' => Yii::t('DonationsModule.base', 'Attempt reconciliation for pending transactions in this space now?'), + ] + ) ?> + +
+ + +
+
+ +imageGalleryPath) !== '' + ? trim((string)$goalForm->imageGalleryPath) + : trim((string)$goalForm->image_path); + +$selectedImageUrlJson = Json::htmlEncode($selectedImageUrl); + +$this->registerCss(<<registerJs(<<No image selected.'); + return; + } + + var image = $('').attr('src', selectedImageUrl); + wrapper.append(image); + } + + function renderGalleryOptions() { + var goalType = $('#donationgoalform-goal_type').val(); + var animalId = $('#donationgoalform-target_animal_id').val(); + var wrapper = $('#donation-goal-gallery-options'); + var emptyState = $('#donation-goal-gallery-empty'); + + wrapper.empty(); + + if (goalType !== 'animal' || !animalId || !galleryMap[animalId] || !galleryMap[animalId].length) { + emptyState.show(); + return; + } + + emptyState.hide(); + + galleryMap[animalId].forEach(function(url) { + var button = $(''); + button.attr('data-url', url); + + if (selectedImageUrl === url) { + button.addClass('is-active'); + } + + button.append($('').attr('src', url).css({ + width: '72px', + height: '72px', + objectFit: 'cover' + })); + + wrapper.append(button); + }); + } + + function toggleAnimalField() { + var goalType = $('#donationgoalform-goal_type').val(); + var wrapper = $('#donation-goal-animal-field'); + + if (goalType === 'animal') { + wrapper.show(); + } else { + wrapper.hide(); + $('#donationgoalform-target_animal_id').val(''); + } + + renderGalleryOptions(); + } + + $(document).on('change', '#donationgoalform-goal_type', toggleAnimalField); + $(document).on('change', '#donationgoalform-target_animal_id', renderGalleryOptions); + $(document).on('click', '.donation-gallery-item', function() { + var url = $(this).data('url') || ''; + setSelectedImage(url); + }); + + $(document).on('change', '#donationgoalform-imagefile', function() { + if (this.files && this.files.length > 0) { + setSelectedImage(''); + } + }); + + toggleAnimalField(); + renderCurrentPreview(); +})(); +JS +);