chore: bootstrap module from working instance and add install guide

This commit is contained in:
Kelin Rescue Hub
2026-04-09 14:18:10 -04:00
parent 97ad7da6f4
commit 6cda47760e
35 changed files with 6267 additions and 4 deletions

View File

@@ -0,0 +1,797 @@
<?php
namespace humhub\modules\donations\controllers;
use humhub\modules\content\components\ContentContainerController;
use humhub\modules\content\components\ContentContainerControllerAccess;
use humhub\modules\donations\models\DonationGoal;
use humhub\modules\donations\models\DonationProviderConfig;
use humhub\modules\donations\models\DonationTransaction;
use humhub\modules\donations\permissions\Donate;
use humhub\modules\donations\services\DonationSettlementService;
use humhub\modules\donations\services\providers\PaymentGatewayService;
use humhub\modules\donations\services\providers\PayPalWebhookService;
use humhub\modules\donations\services\providers\StripeWebhookService;
use humhub\modules\space\models\Space;
use Yii;
use yii\filters\VerbFilter;
use yii\web\ForbiddenHttpException;
use yii\web\Response;
class DonationsController extends ContentContainerController
{
public function behaviors()
{
$behaviors = parent::behaviors();
$behaviors['verbs'] = [
'class' => 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'] ?? ''),
];
}
}

View File

@@ -0,0 +1,616 @@
<?php
namespace humhub\modules\donations\controllers;
use humhub\modules\content\components\ContentContainerController;
use humhub\modules\content\components\ContentContainerControllerAccess;
use humhub\modules\donations\models\DonationGoal;
use humhub\modules\donations\models\DonationProviderConfig;
use humhub\modules\donations\models\DonationSubscription;
use humhub\modules\donations\models\DonationTransaction;
use humhub\modules\donations\models\DonationWebhookEvent;
use humhub\modules\donations\models\forms\DonationGoalForm;
use humhub\modules\donations\models\forms\ProviderSettingsForm;
use humhub\modules\donations\services\DonationSettlementService;
use humhub\modules\donations\services\providers\PaymentGatewayService;
use humhub\modules\donations\services\providers\PayPalWebhookService;
use humhub\modules\donations\services\providers\ProviderCredentialResolver;
use humhub\modules\donations\services\providers\StripeWebhookService;
use humhub\modules\donations\services\ModuleSetupService;
use humhub\modules\rescue_foundation\widgets\RescueSettingsMenu;
use humhub\modules\space\models\Space;
use Yii;
use yii\filters\VerbFilter;
use yii\web\UploadedFile;
use yii\web\BadRequestHttpException;
use yii\web\NotFoundHttpException;
class SettingsController extends ContentContainerController
{
public function behaviors()
{
$behaviors = parent::behaviors();
$behaviors['verbs'] = [
'class' => 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;
}
}