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

435
Events.php Normal file
View File

@@ -0,0 +1,435 @@
<?php
namespace humhub\modules\donations;
use humhub\modules\donations\events\DonationSettlementEvent;
use humhub\modules\donations\models\DonationGoal;
use humhub\modules\donations\models\DonationTransaction;
use humhub\modules\donations\services\DonationAnimalIntegrationService;
use humhub\modules\donations\services\DonationNotificationService;
use humhub\modules\space\models\Space;
use humhub\modules\user\models\User;
use Yii;
use yii\helpers\Html;
class Events
{
public static function onSpaceMenuInit($event): void
{
$space = $event->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' => '<i class="fa fa-heart"></i>',
'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' => '<i class="fa fa-heart"></i>',
'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[] =
'<a href="' . Html::encode($profileUrl) . '" style="display:flex;align-items:center;gap:8px;padding:6px 0;color:#fff;text-decoration:none;">'
. '<img src="' . Html::encode($avatarUrl) . '" alt="" style="width:22px;height:22px;border-radius:999px;border:1px solid rgba(255,255,255,0.35);object-fit:cover;">'
. '<span style="font-size:14px;opacity:0.95;">' . Html::encode($displayName) . '</span>'
. '<span style="margin-left:auto;font-size:14px;font-weight:700;white-space:nowrap;">' . Html::encode($amountText) . '</span>'
. '</a>';
}
}
if ($anonymousTotal > 0) {
$donorRows[] =
'<div style="display:flex;align-items:center;gap:8px;padding:6px 0;color:#fff;">'
. '<span style="width:22px;height:22px;border-radius:999px;border:1px solid rgba(255,255,255,0.35);display:inline-flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.12);">'
. '<i class="fa fa-user" style="font-size:11px;"></i>'
. '</span>'
. '<span style="font-size:14px;opacity:0.95;">' . Html::encode(Yii::t('DonationsModule.base', 'Anonymous')) . '</span>'
. '<span style="margin-left:auto;font-size:14px;font-weight:700;white-space:nowrap;">$' . Html::encode(number_format(round($anonymousTotal), 0)) . '</span>'
. '</div>';
}
}
$donorPageRows = empty($donorRows)
? ['<div style="font-size:13px;color:rgba(255,255,255,0.9);">' . Html::encode(Yii::t('DonationsModule.base', 'No donations yet.')) . '</div>']
: $donorRows;
$donorPageSize = 25;
$donorPages = array_chunk($donorPageRows, $donorPageSize);
$donorTotalPages = max(1, count($donorPages));
$donorPagesHtml = '';
foreach ($donorPages as $pageIndex => $pageRows) {
$pageNumber = $pageIndex + 1;
$donorPagesHtml .= '<div data-donor-page="' . (int)$pageNumber . '" style="display:' . ($pageNumber === 1 ? 'block' : 'none') . ';">'
. implode('', $pageRows)
. '</div>';
}
$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 .= '<input type="radio" id="' . Html::encode($radioId) . '" name="' . Html::encode($donorPagerName) . '" style="display:none;" ' . ($pageNumber === 1 ? 'checked' : '') . '>';
$prevControl = $pageNumber > 1
? '<label for="' . Html::encode($prevRadioId) . '" style="display:inline-block;border:1px solid rgba(255,255,255,0.25);background:rgba(255,255,255,0.08);color:#fff;border-radius:4px;padding:2px 7px;font-size:12px;cursor:pointer;">' . Html::encode(Yii::t('DonationsModule.base', 'Previous')) . '</label>'
: '<span style="display:inline-block;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.45);border-radius:4px;padding:2px 7px;font-size:12px;">' . Html::encode(Yii::t('DonationsModule.base', 'Previous')) . '</span>';
$nextControl = $pageNumber < $donorTotalPages
? '<label for="' . Html::encode($nextRadioId) . '" style="display:inline-block;border:1px solid rgba(255,255,255,0.25);background:rgba(255,255,255,0.08);color:#fff;border-radius:4px;padding:2px 7px;font-size:12px;cursor:pointer;">' . Html::encode(Yii::t('DonationsModule.base', 'Next')) . '</label>'
: '<span style="display:inline-block;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.45);border-radius:4px;padding:2px 7px;font-size:12px;">' . Html::encode(Yii::t('DonationsModule.base', 'Next')) . '</span>';
$pageLabel = Html::encode('Page ' . $pageNumber . ' / ' . $donorTotalPages);
$controlsHtml = '<div data-donor-controls-page="' . (int)$pageNumber . '" style="align-items:center;justify-content:space-between;gap:6px;">'
. $prevControl
. '<span style="font-size:12px;color:rgba(255,255,255,0.9);">' . $pageLabel . '</span>'
. $nextControl
. '</div>';
$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 = '<style>'
. '#' . Html::encode($goalToggleInputId) . ':checked ~ #' . Html::encode($donorPanelId) . '{display:block !important;opacity:1 !important;pointer-events:auto !important;}'
. '#' . Html::encode($quickDonateToggleInputId) . ':checked ~ #' . Html::encode($quickDonatePanelId) . '{display:flex !important;opacity:1 !important;pointer-events:auto !important;}'
. '#' . Html::encode($quickDonatePanelId) . ' input[name="amount"]::-webkit-outer-spin-button,#' . Html::encode($quickDonatePanelId) . ' input[name="amount"]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0;}'
. '[data-animal-tile-overlay-stack]:has(#' . Html::encode($goalToggleInputId) . ':checked),'
. '[data-animal-tile-overlay-stack]:has(#' . Html::encode($quickDonateToggleInputId) . ':checked)'
. '{top:12px !important;bottom:12px !important;display:flex !important;flex-direction:column !important;justify-content:flex-start !important;}'
. '[data-animal-tile-overlay-stack]:has(#' . Html::encode($goalToggleInputId) . ':checked) #' . Html::encode($goalCardId) . ','
. '[data-animal-tile-overlay-stack]:has(#' . Html::encode($quickDonateToggleInputId) . ':checked) #' . Html::encode($goalCardId)
. '{background:rgba(7,10,16,0.34) !important;border-color:rgba(255,255,255,0.24) !important;}'
. $donorPagerCss
. '</style>';
$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 = '<div id="' . Html::encode($quickDonatePanelId) . '" style="display:none;opacity:0;pointer-events:none;margin:8px 12px 0;padding:20px 20px;border-radius:7px;background:rgba(0,0,0,0.28);border:1px solid rgba(255,255,255,0.15);overflow:hidden;max-width:fit-content;width:fit-content;justify-self:center;align-self:center;">'
. '<form method="post" action="' . Html::encode($quickDonateActionUrl) . '" style="margin:0;display:flex;flex-direction:column;align-items:center;text-align:center;">'
. Html::hiddenInput(Yii::$app->request->csrfParam, Yii::$app->request->getCsrfToken())
. Html::hiddenInput('goal_id', (int)$goal->id)
. '<div style="margin:0 0 8px 0;font-size:13px;font-weight:700;color:#fff;text-align:center;hyphens:none;word-break:normal;overflow-wrap:normal;white-space:normal;">' . Html::encode($quickDonateHeaderText) . '</div>'
. '<div style="display:flex;align-items:center;justify-content:center;gap:6px;margin-bottom:8px;">'
. '<label style="margin:0;font-size:24px;font-weight:700;color:#fff;line-height:1;">$</label>'
. '<input type="number" step="0.01" min="0.01" name="amount" required class="form-control input-sm" style="width:100px;max-width:100px;min-width:100px;background:rgba(54,209,124,0.32) !important;border:1px solid rgba(54,209,124,0.72) !important;border-radius:999px !important;color:#f8fafc !important;-webkit-text-fill-color:#f8fafc;box-shadow:none !important;font-size:24px;font-weight:700;line-height:1.15;text-align:center;padding:4px 10px;appearance:textfield;-moz-appearance:textfield;overflow:hidden;">'
. '</div>'
. '<div style="display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap;margin-bottom:8px;">'
. '<label style="margin:0;display:flex;align-items:center;gap:6px;color:#fff;font-size:13px;cursor:pointer;">'
. '<input type="radio" name="mode" value="one_time" checked>'
. Html::encode(Yii::t('DonationsModule.base', 'One Time'))
. '</label>'
. '<label style="margin:0;display:flex;align-items:center;gap:6px;color:#fff;font-size:13px;cursor:pointer;">'
. '<input type="radio" name="mode" value="recurring">'
. Html::encode(Yii::t('DonationsModule.base', 'Recurring'))
. '</label>'
. '</div>'
. '<label style="margin:0 0 10px 0;display:flex;align-items:center;justify-content:center;gap:6px;color:#fff;font-size:13px;cursor:pointer;">'
. '<input type="checkbox" name="is_anonymous" value="1">'
. Html::encode(Yii::t('DonationsModule.base', 'Donate anonymously'))
. '</label>'
. '<div style="display:flex;align-items:center;gap:8px;justify-content:center;">'
. '<button type="submit" name="provider" value="paypal" class="btn btn-sm" style="min-width:92px;border:1px solid rgba(255,255,255,0.28);background:rgba(255,255,255,0.12);color:#fff;">PayPal</button>'
. '<button type="submit" name="provider" value="stripe" class="btn btn-sm" style="min-width:92px;border:1px solid rgba(255,255,255,0.28);background:rgba(255,255,255,0.12);color:#fff;">Stripe</button>'
. '</div>'
. '</form>'
. '</div>';
$donorPanelHtml = $goalToggleCss
. '<input type="checkbox" id="' . Html::encode($goalToggleInputId) . '" style="display:none;" onchange="if(this.checked){var q=document.getElementById(\'' . Html::encode($quickDonateToggleInputId) . '\');if(q){q.checked=false;}}">'
. '<input type="checkbox" id="' . Html::encode($quickDonateToggleInputId) . '" style="display:none;" onchange="if(this.checked){var g=document.getElementById(\'' . Html::encode($goalToggleInputId) . '\');if(g){g.checked=false;}}">'
. '<div id="' . Html::encode($donorPanelId) . '" style="display:none;opacity:0;pointer-events:none;margin-top:8px;padding:8px 10px;border-radius:7px;background:rgba(0,0,0,0.28);border:1px solid rgba(255,255,255,0.15);overflow:hidden;max-height:calc(100% - 98px);">'
. '<div id="' . Html::encode($donorPagerWrapId) . '">'
. $donorPagerRadioInputsHtml
. '<div data-donor-top-controls style="' . ($donorTotalPages > 1 ? 'margin-bottom:6px;' : 'display:none;') . '">' . $donorPagerTopControlsHtml . '</div>'
. '<div data-donor-pages data-donor-list-scroll style="overflow-y:auto;max-height:calc(100% - 72px);">' . $donorPagesHtml . '</div>'
. '<div data-donor-bottom-controls style="' . ($donorTotalPages > 1 ? 'margin-top:6px;' : 'display:none;') . '">' . $donorPagerBottomControlsHtml . '</div>'
. '</div>'
. '</div>';
$goalLabelHtml = '<label for="' . Html::encode($goalToggleInputId) . '" style="margin:0;cursor:pointer;font-weight:700;color:rgba(255,255,255,0.98);pointer-events:auto;position:relative;z-index:4;">'
. Html::encode(Yii::t('DonationsModule.base', 'Goal'))
. '</label>';
$percentColor = $percent >= 100.0 ? '#4ce083' : '#ffffff';
$currentAmountHtml = '<span style="font-weight:700;color:#ffffff;white-space:nowrap;">' . Html::encode($currentLabel) . '</span>';
$targetAmountNumberLabel = ltrim($targetLabel, '$');
if ($targetAmountNumberLabel === '') {
$targetAmountNumberLabel = $targetLabel;
}
$targetAmountInnerHtml = '<span style="display:inline-flex;align-items:flex-end;gap:2px;line-height:1;">'
. '<span style="font-size:13px;line-height:1;transform:translateY(-0.36em);">$</span>'
. '<span style="font-size:21px;line-height:1;">' . Html::encode($targetAmountNumberLabel) . '</span>'
. '</span>';
$donateCtaHtml = '<span style="display:inline-flex;align-items:center;justify-content:center;height:16px;padding:4px 14px;border-radius:999px;border:1px solid rgba(30,164,95,0.62);background:linear-gradient(90deg,#36d17c,#1ea45f);color:#000000;font-size:12px;font-weight:800;letter-spacing:.08em;line-height:1;text-transform:uppercase;box-shadow:0 0 0 1px rgba(255,255,255,0.2),0 0 10px rgba(54,209,124,0.62),0 0 18px rgba(30,164,95,0.46);text-shadow:0 0 2px rgba(255,255,255,0.28);">'
. Html::encode(Yii::t('DonationsModule.base', 'Donate'))
. '</span>';
$targetAmountHtml = '<span style="margin-left:auto;font-weight:700;color:' . $statusColor . ';white-space:nowrap;display:inline-flex;align-items:flex-end;">' . $targetAmountInnerHtml . '</span>';
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 = '<label for="' . Html::encode($toggleInputId) . '" class="js-animal-donation-inline-toggle"'
. ' data-toggle-id="#' . Html::encode($toggleInputId) . '"'
. ($inlineFormId !== '' ? ' data-form-id="#' . Html::encode($inlineFormId) . '"' : '')
. $targetOnClick
. ' style="margin:0 0 0 auto;cursor:pointer;font-weight:700;color:' . $statusColor . ';pointer-events:auto;position:relative;z-index:4;text-decoration:none;white-space:nowrap;display:inline-flex;align-items:flex-end;">'
. $targetAmountInnerHtml
. '</label>';
}
$event->addHtml(
'<div id="' . Html::encode($goalCardId) . '" style="display:block;position:relative;overflow:visible;width:100%;padding:7px 8px;border-radius:8px;background:rgba(7,10,16,0.16);border:1px solid rgba(255,255,255,0.14);box-sizing:border-box;transition:background 140ms ease,border-color 140ms ease;">'
. '<div style="position:relative;">'
. '<label for="' . Html::encode($quickDonateToggleInputId) . '" style="position:absolute;inset:0;z-index:2;cursor:pointer;"></label>'
. '<div style="position:relative;z-index:3;display:flex;gap:8px;align-items:flex-end;font-size:16.5px;color:rgba(255,255,255,0.95);pointer-events:none;">'
. $goalLabelHtml
. $targetAmountHtml
. '</div>'
. '<div style="margin-top:5px;height:7px;border-radius:999px;overflow:hidden;background:rgba(255,255,255,0.22);pointer-events:none;">'
. '<span style="display:block;height:100%;width:' . Html::encode((string)$percent) . '%;background:linear-gradient(90deg,#36d17c,#1ea45f);"></span>'
. '</div>'
. '<div style="margin-top:4px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:end;column-gap:10px;font-size:16.5px;font-weight:700;pointer-events:none;">'
. $currentAmountHtml
. $donateCtaHtml
. '<span style="justify-self:end;white-space:nowrap;text-align:right;color:' . $percentColor . ';">' . Html::encode($percentLabel) . '</span>'
. '</div>'
. '</div>'
. $donorPanelHtml
. $quickDonatePanelHtml
. '</div>'
);
}
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);
}
}

63
INSTALL.md Normal file
View File

@@ -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
```

38
Module.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
namespace humhub\modules\donations;
use humhub\modules\content\components\ContentContainerActiveRecord;
use humhub\modules\content\components\ContentContainerModule;
use humhub\modules\space\models\Space;
class Module extends ContentContainerModule
{
public $resourcesPath = 'resources';
public function getContentContainerTypes()
{
return [Space::class];
}
public function getPermissions($contentContainer = null)
{
if ($contentContainer instanceof Space) {
return [
new permissions\ManageDonations(),
new permissions\Donate(),
];
}
return [];
}
public function getContentContainerConfigUrl(ContentContainerActiveRecord $container)
{
if ($container->moduleManager->isEnabled('rescue_foundation')) {
return $container->createUrl('/rescue_foundation/settings');
}
return $container->createUrl('/donations/settings');
}
}

51
README.md Normal file
View File

@@ -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://<your-host>/s/<space-url>/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

View File

@@ -1,4 +0,0 @@
# Donations module for HumHub
Enables PayPal & Stripe Payments
Integrates into animal-management module

20
config.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
use humhub\modules\donations\Events;
use humhub\modules\space\widgets\HeaderControlsMenu;
use humhub\modules\space\widgets\Menu;
return [
'id' => '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']],
],
];

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;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace humhub\modules\donations\events;
use humhub\modules\donations\models\DonationGoal;
use humhub\modules\donations\models\DonationTransaction;
use yii\base\Event;
class DonationSettlementEvent extends Event
{
public DonationTransaction $transaction;
public ?DonationGoal $goal = null;
public array $payload = [];
}

View File

@@ -0,0 +1,157 @@
<?php
use humhub\components\Migration;
class m260404_000000_initial extends Migration
{
public function safeUp()
{
$this->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');
}
}

46
models/DonationBlock.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
namespace humhub\modules\donations\models;
use humhub\components\ActiveRecord;
class DonationBlock extends ActiveRecord
{
public static function tableName()
{
return 'rescue_donation_block';
}
public function rules()
{
return [
[['contentcontainer_id', 'placement'], 'required'],
[['contentcontainer_id', 'goal_id', 'is_active', 'sort_order'], 'integer'],
[['placement'], 'string', 'max' => 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;
}
}

69
models/DonationGoal.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
namespace humhub\modules\donations\models;
use humhub\components\ActiveRecord;
use Yii;
class DonationGoal extends ActiveRecord
{
public const TYPE_RESCUE_GENERAL = 'rescue_general';
public const TYPE_ANIMAL = 'animal';
public const TYPE_NEED = 'need';
public const TYPE_SPECIAL_NEED = 'special_need';
public const TYPE_SPONSORSHIP = 'sponsorship';
public static function tableName()
{
return 'rescue_donation_goal';
}
public function rules()
{
return [
[['contentcontainer_id', 'goal_type', 'title'], 'required'],
[['contentcontainer_id', 'target_animal_id', 'is_active'], 'integer'],
[['description'], 'string'],
[['target_amount', 'current_amount'], 'number', 'min' => 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;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace humhub\modules\donations\models;
use humhub\components\ActiveRecord;
class DonationProviderConfig extends ActiveRecord
{
public static function tableName()
{
return 'rescue_donation_provider_config';
}
public function rules()
{
return [
[['contentcontainer_id'], 'required'],
[['contentcontainer_id'], 'integer'],
[['paypal_enabled', 'paypal_recurring_enabled', 'stripe_enabled', 'stripe_recurring_enabled', 'sandbox_mode'], 'boolean'],
[['paypal_client_id', 'paypal_client_secret', 'paypal_webhook_id', 'stripe_publishable_key', 'stripe_secret_key', 'stripe_webhook_secret'], 'string', 'max' => 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;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace humhub\modules\donations\models;
use humhub\components\ActiveRecord;
class DonationSubscription extends ActiveRecord
{
public static function tableName()
{
return 'rescue_donation_subscription';
}
public function rules()
{
return [
[['contentcontainer_id', 'provider', 'provider_subscription_id', 'status', 'amount'], 'required'],
[['contentcontainer_id', 'donor_user_id', 'goal_id', 'interval_count'], 'integer'],
[['amount'], 'number', 'min' => 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;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace humhub\modules\donations\models;
use humhub\components\ActiveRecord;
class DonationTransaction extends ActiveRecord
{
public const STATUS_PENDING = 'pending';
public const STATUS_SUCCEEDED = 'succeeded';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_REFUNDED = 'refunded';
public static function tableName()
{
return 'rescue_donation_transaction';
}
public function rules()
{
return [
[['contentcontainer_id', 'provider', 'mode', 'status', 'amount'], 'required'],
[['contentcontainer_id', 'donor_user_id', 'is_anonymous', 'goal_id', 'target_animal_id'], 'integer'],
[['amount'], 'number', 'min' => 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;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace humhub\modules\donations\models;
use humhub\components\ActiveRecord;
class DonationWebhookEvent extends ActiveRecord
{
public static function tableName()
{
return 'rescue_donation_webhook_event';
}
public function rules()
{
return [
[['provider', 'provider_event_id'], 'required'],
[['is_processed'], 'integer'],
[['payload_json'], 'string'],
[['provider'], 'string', 'max' => 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;
}
}

View File

@@ -0,0 +1,271 @@
<?php
namespace humhub\modules\donations\models\forms;
use humhub\modules\content\components\ContentContainerActiveRecord;
use humhub\modules\donations\models\DonationGoal;
use humhub\modules\donations\models\DonationProviderConfig;
use humhub\modules\rescue_foundation\components\UploadStandards;
use Yii;
use yii\base\Model;
use yii\helpers\FileHelper;
use yii\web\UploadedFile;
class DonationGoalForm extends Model
{
public ContentContainerActiveRecord $contentContainer;
public $id = null;
public $goal_type = DonationGoal::TYPE_RESCUE_GENERAL;
public $target_animal_id = null;
public $title = '';
public $description = '';
public $image_path = '';
public UploadedFile|string|null $imageFile = null;
public $imageGalleryPath = '';
public $target_amount = 0.0;
public $current_amount = 0.0;
public $is_active = true;
public function rules()
{
return [
[['goal_type', 'title', 'target_amount'], 'required'],
[['id', 'target_animal_id'], 'integer'],
[['description'], 'string'],
[['target_amount', 'current_amount'], 'number', 'min' => 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;
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace humhub\modules\donations\models\forms;
use humhub\modules\content\components\ContentContainerActiveRecord;
use humhub\modules\donations\models\DonationProviderConfig;
use Yii;
use yii\base\Model;
class ProviderSettingsForm extends Model
{
public ContentContainerActiveRecord $contentContainer;
public $sandbox_mode = true;
public $default_currency = 'USD';
public $animal_tile_extra_height_px = 0;
public $animal_donation_form_header = '';
public $paypal_enabled = false;
public $paypal_recurring_enabled = false;
public $paypal_sandbox_client_id = '';
public $paypal_sandbox_client_secret = '';
public $paypal_sandbox_webhook_id = '';
public $paypal_live_client_id = '';
public $paypal_live_client_secret = '';
public $paypal_live_webhook_id = '';
public $stripe_enabled = false;
public $stripe_recurring_enabled = false;
public $stripe_sandbox_publishable_key = '';
public $stripe_sandbox_secret_key = '';
public $stripe_sandbox_webhook_secret = '';
public $stripe_live_publishable_key = '';
public $stripe_live_secret_key = '';
public $stripe_live_webhook_secret = '';
public function rules()
{
return [
[['sandbox_mode', 'paypal_enabled', 'paypal_recurring_enabled', 'stripe_enabled', 'stripe_recurring_enabled'], 'boolean'],
[['animal_tile_extra_height_px'], 'integer', 'min' => 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;
}
}

15
module.json Normal file
View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,21 @@
<?php
namespace humhub\modules\donations\notifications;
use humhub\modules\notification\components\NotificationCategory;
use Yii;
class DonationNotificationCategory extends NotificationCategory
{
public $id = 'donations_activity';
public function getTitle()
{
return Yii::t('DonationsModule.base', 'Donations');
}
public function getDescription()
{
return Yii::t('DonationsModule.base', 'Receive notifications about successful or refunded donations.');
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace humhub\modules\donations\notifications;
use humhub\libs\Html;
use humhub\modules\notification\components\BaseNotification;
use humhub\modules\space\models\Space;
use Yii;
use yii\helpers\Json;
use yii\helpers\Url;
class DonationRefundedNotification extends BaseNotification
{
public $moduleId = 'donations';
public $requireSource = false;
public $requireOriginator = false;
public string $spaceGuid = '';
public string $goalTitle = '';
public string $amountLabel = '';
public function category()
{
return new DonationNotificationCategory();
}
public function getUrl()
{
$spaceGuid = $this->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);
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace humhub\modules\donations\notifications;
use humhub\libs\Html;
use humhub\modules\notification\components\BaseNotification;
use humhub\modules\space\models\Space;
use Yii;
use yii\helpers\Json;
use yii\helpers\Url;
class DonationSucceededNotification extends BaseNotification
{
public $moduleId = 'donations';
public $requireSource = false;
public $requireOriginator = false;
public string $spaceGuid = '';
public string $goalTitle = '';
public string $amountLabel = '';
public function category()
{
return new DonationNotificationCategory();
}
public function getUrl()
{
$spaceGuid = $this->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);
}
}

30
permissions/Donate.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
namespace humhub\modules\donations\permissions;
use humhub\libs\BasePermission;
use humhub\modules\space\models\Space;
use Yii;
class Donate extends BasePermission
{
public $moduleId = 'donations';
public $defaultAllowedGroups = [
Space::USERGROUP_OWNER,
Space::USERGROUP_ADMIN,
Space::USERGROUP_MODERATOR,
Space::USERGROUP_USER,
Space::USERGROUP_GUEST,
];
public function getTitle()
{
return Yii::t('DonationsModule.base', 'Donate');
}
public function getDescription()
{
return Yii::t('DonationsModule.base', 'Allows users to donate to rescue, animal, and special goals.');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace humhub\modules\donations\permissions;
use humhub\libs\BasePermission;
use humhub\modules\space\models\Space;
use Yii;
class ManageDonations extends BasePermission
{
public $moduleId = 'donations';
public $defaultAllowedGroups = [
Space::USERGROUP_OWNER,
Space::USERGROUP_ADMIN,
Space::USERGROUP_MODERATOR,
];
protected $fixedGroups = [
Space::USERGROUP_USER,
Space::USERGROUP_GUEST,
];
public function getTitle()
{
return Yii::t('DonationsModule.base', 'Manage donations');
}
public function getDescription()
{
return Yii::t('DonationsModule.base', 'Allows configuring provider options, donation goals, and donation blocks.');
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace humhub\modules\donations\services;
use humhub\modules\donations\models\DonationGoal;
use humhub\modules\donations\models\DonationTransaction;
use Yii;
class DonationAnimalIntegrationService
{
public function applySucceededIntegration(DonationTransaction $transaction, ?DonationGoal $goal, array $payload): array
{
if (!empty($payload['animal_progress_succeeded_created'])) {
return $payload;
}
return $this->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;
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace humhub\modules\donations\services;
use humhub\modules\donations\models\DonationGoal;
use humhub\modules\donations\models\DonationTransaction;
use humhub\modules\donations\notifications\DonationRefundedNotification;
use humhub\modules\donations\notifications\DonationSucceededNotification;
use humhub\modules\space\models\Space;
use humhub\modules\user\models\User;
use Yii;
class DonationNotificationService
{
public function applySucceededNotifications(DonationTransaction $transaction, ?DonationGoal $goal, array $payload): array
{
if (!empty($payload['donation_succeeded_notifications_sent'])) {
return $payload;
}
return $this->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);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace humhub\modules\donations\services;
use humhub\modules\donations\events\DonationSettlementEvent;
use humhub\modules\donations\models\DonationGoal;
use humhub\modules\donations\models\DonationTransaction;
use yii\base\Event;
class DonationSettlementService
{
public const EVENT_AFTER_SUCCEEDED = 'afterSucceeded';
public const EVENT_AFTER_REFUNDED = 'afterRefunded';
public function markSucceededAndApply(DonationTransaction $transaction, array $metadata = []): void
{
$payload = $this->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 : [];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace humhub\modules\donations\services;
use humhub\modules\donations\models\DonationProviderConfig;
use humhub\modules\space\models\Space;
use Yii;
class ModuleSetupService
{
public static function runForSpace(Space $space): array
{
$result = static::applyModuleMigrations();
$config = DonationProviderConfig::findOne(['contentcontainer_id' => $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,
];
}
}

View File

@@ -0,0 +1,413 @@
<?php
namespace humhub\modules\donations\services\providers;
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\services\DonationSettlementService;
use Yii;
class PayPalWebhookService
{
public function process(string $payload, array $headers, ?DonationProviderConfig $providerConfig = null): array
{
$event = json_decode($payload, true);
if (!is_array($event) || empty($event['id']) || empty($event['event_type'])) {
return ['ok' => 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,
];
}
}

View File

@@ -0,0 +1,618 @@
<?php
namespace humhub\modules\donations\services\providers;
use humhub\modules\donations\models\DonationGoal;
use humhub\modules\donations\models\DonationProviderConfig;
use humhub\modules\donations\models\DonationTransaction;
class PaymentGatewayService
{
public function createCheckout(
DonationTransaction $transaction,
DonationGoal $goal,
DonationProviderConfig $providerConfig,
string $successUrl,
string $cancelUrl
): array {
if ($transaction->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,
];
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace humhub\modules\donations\services\providers;
use humhub\modules\donations\models\DonationProviderConfig;
use humhub\modules\space\models\Space;
use Yii;
class ProviderCredentialResolver
{
public static function isSandboxMode(DonationProviderConfig $config): bool
{
$settings = self::settingsForConfig($config);
$value = $settings !== null
? $settings->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);
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace humhub\modules\donations\services\providers;
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\services\DonationSettlementService;
use Yii;
class StripeWebhookService
{
public function process(string $payload, string $signatureHeader, ?DonationProviderConfig $providerConfig = null): array
{
$event = json_decode($payload, true);
if (!is_array($event) || empty($event['id']) || empty($event['type'])) {
return ['ok' => 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;
}
}

341
views/donations/index.php Normal file
View File

@@ -0,0 +1,341 @@
<?php
use humhub\modules\donations\models\DonationGoal;
use humhub\modules\donations\models\DonationProviderConfig;
use humhub\modules\space\models\Space;
use yii\helpers\Html;
/* @var DonationGoal[] $goals */
/* @var DonationProviderConfig|null $providerConfig */
/* @var bool $canDonate */
/* @var array $providerOptions */
/* @var array $recurringOptions */
/* @var Space $space */
/* @var bool $schemaReady */
/* @var array $dashboardData */
$dashboardData = is_array($dashboardData ?? null) ? $dashboardData : [];
$isManager = (bool)($dashboardData['isManager'] ?? false);
$months = is_array($dashboardData['months'] ?? null) ? $dashboardData['months'] : [];
$selectedMonth = (string)($dashboardData['selectedMonth'] ?? '');
$userRows = is_array($dashboardData['userRows'] ?? null) ? $dashboardData['userRows'] : [];
$userMonthlyHeaders = is_array($dashboardData['userMonthlyHeaders'] ?? null) ? $dashboardData['userMonthlyHeaders'] : [];
$userGrandTotal = (float)($dashboardData['userGrandTotal'] ?? 0);
$adminRows = is_array($dashboardData['adminRows'] ?? null) ? $dashboardData['adminRows'] : [];
$adminTotals = is_array($dashboardData['adminTotals'] ?? null) ? $dashboardData['adminTotals'] : ['target' => 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;
?>
<style>
.donations-view-toggle {
width: 32px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
.donations-total-amount {
font-size: 28px;
font-weight: 800;
color: #1c7c45;
line-height: 1;
margin-left: 10px;
}
.donations-summary-table thead th {
text-align: center;
padding-left: 14px;
}
.donations-summary-table td {
padding-left: 14px;
}
.donations-summary-table .donation-subtotals-row {
background: #f8fafc;
}
.donation-breakdown-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-top: 1px solid #eef2f6;
padding: 4px 0;
}
.donation-breakdown-row .donation-breakdown-date {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.donation-breakdown-row .donation-breakdown-amount {
min-width: 110px;
text-align: right;
font-weight: 700;
}
@media (prefers-color-scheme: dark) {
.donations-summary-table .donation-subtotals-row {
background: #1f2937;
color: #e5e7eb;
}
}
</style>
<div class="panel panel-default">
<div class="panel-heading" style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
<span><?= Yii::t('DonationsModule.base', '<strong>My Donations</strong>') ?></span>
<span style="display:flex;gap:6px;align-items:center;">
<?= Html::a('<i class="fa fa-th-large"></i>', $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')]) ?>
<?= Html::a('<i class="fa fa-table"></i>', $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')]) ?>
</span>
</div>
<div class="panel-body">
<?php if (!$schemaReady): ?>
<div class="alert alert-warning" style="margin-bottom:14px;">
<?= Yii::t('DonationsModule.base', 'Donations setup has not been run yet. Please ask a space admin to run module setup.') ?>
</div>
<?php endif; ?>
<?php if ($schemaReady && Yii::$app->user->isGuest): ?>
<div class="alert alert-info" style="margin-bottom:14px;">
<?= Yii::t('DonationsModule.base', 'Sign in to see your personal donation history dashboard.') ?>
</div>
<?php endif; ?>
<?php if ($schemaReady): ?>
<div class="row" style="margin-top:2px;">
<?php if ($hasUserDashboard): ?>
<div class="col-md-8 col-sm-12" style="min-width:0;">
<div style="margin-bottom:14px;display:flex;align-items:flex-end;flex-wrap:wrap;gap:8px;">
<span style="font-size:14px;font-weight:700;"><?= Yii::t('DonationsModule.base', 'Your Total Contributions') ?>:</span>
<span class="donations-total-amount">$<?= Html::encode(number_format($userGrandTotal, 2)) ?></span>
</div>
<?php if (empty($userRows)): ?>
<div class="alert alert-info" style="margin-bottom:14px;">
<?= Yii::t('DonationsModule.base', 'No successful donations from your account were found in this space yet.') ?>
</div>
<?php elseif ($layout === 'table'): ?>
<div class="table-responsive" style="margin-bottom:14px;">
<table class="table table-bordered table-hover donations-summary-table" style="margin-bottom:0;">
<thead>
<tr>
<th><?= Yii::t('DonationsModule.base', 'Animal') ?></th>
<?php foreach ($userMonthlyHeaders as $monthKey): ?>
<th><?= Html::encode(date('M Y', strtotime($monthKey . '-01'))) ?></th>
<?php endforeach; ?>
<th><?= Yii::t('DonationsModule.base', 'Annual Total') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Lifetime Total') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($userRows as $row): ?>
<?php
$annualTotal = 0.0;
foreach ((array)($row['annual'] ?? []) as $yearAmount) {
$annualTotal += (float)$yearAmount;
}
?>
<tr>
<td>
<?php if (!empty($row['animalUrl'])): ?>
<?= Html::a(Html::encode((string)$row['animalName']), (string)$row['animalUrl']) ?>
<?php else: ?>
<?= Html::encode((string)$row['animalName']) ?>
<?php endif; ?>
</td>
<?php foreach ($userMonthlyHeaders as $monthKey): ?>
<td>$<?= Html::encode(number_format((float)($row['monthly'][$monthKey] ?? 0), 2)) ?></td>
<?php endforeach; ?>
<td>$<?= Html::encode(number_format($annualTotal, 2)) ?></td>
<td><strong>$<?= Html::encode(number_format((float)($row['total'] ?? 0), 2)) ?></strong></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="row" style="margin-bottom:6px;">
<?php foreach ($userRows as $row): ?>
<?php
$goalTarget = max(0.0, (float)($row['goalTarget'] ?? 0));
$goalCurrent = max(0.0, (float)($row['goalCurrent'] ?? 0));
$goalPercent = $goalTarget > 0 ? min(100.0, ($goalCurrent / $goalTarget) * 100.0) : 0.0;
$annualTotals = (array)($row['annual'] ?? []);
krsort($annualTotals, SORT_NUMERIC);
?>
<div class="<?= Html::encode($userGridClass) ?>" style="margin-bottom:12px;">
<div class="panel panel-default" style="margin-bottom:0;overflow:hidden;">
<?php if (!empty($row['imageUrl'])): ?>
<img src="<?= Html::encode((string)$row['imageUrl']) ?>" alt="<?= Html::encode((string)$row['animalName']) ?>" style="width:100%;height:165px;object-fit:cover;display:block;">
<?php else: ?>
<div style="height:165px;background:#eef2f6;display:flex;align-items:center;justify-content:center;color:#98a2b3;">
<i class="fa fa-heart fa-2x"></i>
</div>
<?php endif; ?>
<div class="panel-body" style="padding:10px;">
<div style="font-size:16px;font-weight:700;line-height:1.2;margin-bottom:4px;">
<?php if (!empty($row['animalUrl'])): ?>
<?= Html::a(Html::encode((string)$row['animalName']), (string)$row['animalUrl']) ?>
<?php else: ?>
<?= Html::encode((string)$row['animalName']) ?>
<?php endif; ?>
</div>
<div style="font-size:13px;margin-bottom:8px;color:#344054;">
<?= Yii::t('DonationsModule.base', 'Your Contribution') ?>:
<strong>$<?= Html::encode(number_format((float)($row['total'] ?? 0), 2)) ?></strong>
</div>
<div class="progress" style="height:9px;margin-bottom:6px;">
<div class="progress-bar progress-bar-success" role="progressbar" style="width:<?= Html::encode((string)round($goalPercent, 2)) ?>%;"></div>
</div>
<div style="font-size:12px;color:#667085;margin-bottom:8px;">
<?= Yii::t('DonationsModule.base', 'Goal') ?>:
$<?= Html::encode(number_format($goalCurrent, 2)) ?> / $<?= Html::encode(number_format($goalTarget, 2)) ?>
(<?= Html::encode(number_format($goalPercent, 1)) ?>%)
</div>
<div style="font-size:12px;font-weight:700;margin-bottom:4px;"><?= Yii::t('DonationsModule.base', 'Monthly Breakdown') ?></div>
<div style="max-height:120px;overflow:auto;">
<?php foreach ($userMonthlyHeaders as $monthKey): ?>
<div class="donation-breakdown-row">
<span class="donation-breakdown-date"><?= Html::encode(date('M Y', strtotime($monthKey . '-01'))) ?></span>
<span class="donation-breakdown-amount">$<?= Html::encode(number_format((float)($row['monthly'][$monthKey] ?? 0), 2)) ?></span>
</div>
<?php endforeach; ?>
</div>
<div style="font-size:12px;font-weight:700;margin-top:8px;margin-bottom:4px;"><?= Yii::t('DonationsModule.base', 'Annual Totals') ?></div>
<?php foreach ($annualTotals as $year => $amount): ?>
<div class="donation-breakdown-row">
<span class="donation-breakdown-date"><?= Html::encode((string)$year) ?></span>
<span class="donation-breakdown-amount">$<?= Html::encode(number_format((float)$amount, 2)) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="<?= $hasUserDashboard ? 'col-md-4 col-sm-12' : 'col-md-12 col-sm-12' ?>" style="min-width:0;">
<div class="panel panel-default" style="margin-bottom:12px;">
<div class="panel-heading" style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<span><?= Yii::t('DonationsModule.base', 'All Donation Progress by Month') ?></span>
<span style="display:flex;align-items:center;gap:6px;">
<?php if ($prevMonth !== ''): ?>
<?= Html::a('<i class="fa fa-chevron-left"></i>', $buildPageUrl(['month' => $prevMonth]), ['class' => 'btn btn-default btn-xs', 'title' => Yii::t('DonationsModule.base', 'Previous Month')]) ?>
<?php endif; ?>
<span style="font-size:12px;font-weight:700;"><?= Html::encode($selectedMonth !== '' ? date('F Y', strtotime($selectedMonth . '-01')) : Yii::t('DonationsModule.base', 'Current Month')) ?></span>
<?php if ($nextMonth !== ''): ?>
<?= Html::a('<i class="fa fa-chevron-right"></i>', $buildPageUrl(['month' => $nextMonth]), ['class' => 'btn btn-default btn-xs', 'title' => Yii::t('DonationsModule.base', 'Next Month')]) ?>
<?php endif; ?>
</span>
</div>
<div class="table-responsive">
<table class="table table-bordered table-striped donations-summary-table" style="margin-bottom:0;">
<thead>
<tr>
<th><?= Yii::t('DonationsModule.base', 'Animal') ?></th>
<th><?= Yii::t('DonationsModule.base', '$ Target') ?></th>
<th><?= Yii::t('DonationsModule.base', '$ Donated') ?></th>
<th><?= Yii::t('DonationsModule.base', '% of Goal') ?></th>
</tr>
<tr class="donation-subtotals-row">
<th><?= Yii::t('DonationsModule.base', 'Subtotals') ?></th>
<th>$<?= Html::encode(number_format((float)($adminTotals['target'] ?? 0), 2)) ?></th>
<th>$<?= Html::encode(number_format((float)($adminTotals['donated'] ?? 0), 2)) ?></th>
<th><?= Html::encode(number_format((float)($adminTotals['percent'] ?? 0), 1)) ?>%</th>
</tr>
</thead>
<tbody>
<?php if (empty($adminRows)): ?>
<tr>
<td colspan="4" class="text-muted"><?= Yii::t('DonationsModule.base', 'No donation data for this month.') ?></td>
</tr>
<?php else: ?>
<?php foreach ($adminRows as $row): ?>
<tr>
<td>
<?php if (!empty($row['animalUrl'])): ?>
<?= Html::a(Html::encode((string)$row['animalName']), (string)$row['animalUrl']) ?>
<?php else: ?>
<?= Html::encode((string)$row['animalName']) ?>
<?php endif; ?>
</td>
<td>$<?= Html::encode(number_format((float)($row['target'] ?? 0), 2)) ?></td>
<td>$<?= Html::encode(number_format((float)($row['donated'] ?? 0), 2)) ?></td>
<td><?= Html::encode(number_format((float)($row['percent'] ?? 0), 1)) ?>%</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<div class="panel panel-default" style="margin-bottom:12px;">
<div class="panel-heading"><?= Yii::t('DonationsModule.base', 'Year to Date Overview') ?></div>
<div class="panel-body">
<div style="font-size:13px;margin-bottom:4px;"><?= Yii::t('DonationsModule.base', 'Year') ?>: <strong><?= Html::encode((string)($ytd['year'] ?? date('Y'))) ?></strong></div>
<div style="font-size:13px;margin-bottom:4px;"><?= Yii::t('DonationsModule.base', 'Donated') ?>: <strong>$<?= Html::encode(number_format((float)($ytd['donated'] ?? 0), 2)) ?></strong></div>
<div style="font-size:13px;margin-bottom:4px;"><?= Yii::t('DonationsModule.base', 'Target') ?>: <strong>$<?= Html::encode(number_format((float)($ytd['target'] ?? 0), 2)) ?></strong></div>
<div style="font-size:13px;"><?= Yii::t('DonationsModule.base', 'Progress') ?>: <strong><?= Html::encode(number_format((float)($ytd['percent'] ?? 0), 1)) ?>%</strong></div>
</div>
</div>
<?php if (is_array($previousYear)): ?>
<div class="panel panel-default" style="margin-bottom:12px;">
<div class="panel-heading"><?= Yii::t('DonationsModule.base', 'Annual Overview') ?> (<?= Html::encode((string)($previousYear['year'] ?? '')) ?>)</div>
<div class="panel-body">
<div style="font-size:13px;margin-bottom:4px;"><?= Yii::t('DonationsModule.base', 'Donated') ?>: <strong>$<?= Html::encode(number_format((float)($previousYear['donated'] ?? 0), 2)) ?></strong></div>
<div style="font-size:13px;margin-bottom:4px;"><?= Yii::t('DonationsModule.base', 'Target') ?>: <strong>$<?= Html::encode(number_format((float)($previousYear['target'] ?? 0), 2)) ?></strong></div>
<div style="font-size:13px;"><?= Yii::t('DonationsModule.base', 'Progress') ?>: <strong><?= Html::encode(number_format((float)($previousYear['percent'] ?? 0), 1)) ?>%</strong></div>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>

146
views/settings/history.php Normal file
View File

@@ -0,0 +1,146 @@
<?php
use humhub\modules\donations\models\DonationSubscription;
use humhub\modules\donations\models\DonationTransaction;
use humhub\modules\donations\models\DonationWebhookEvent;
use humhub\modules\space\models\Space;
use yii\helpers\Html;
/* @var string|null $subNav */
/* @var DonationTransaction[] $transactions */
/* @var DonationSubscription[] $subscriptions */
/* @var DonationWebhookEvent[] $webhookEvents */
/* @var Space $space */
?>
<div class="panel panel-default">
<div class="panel-heading"><?= Yii::t('DonationsModule.base', '<strong>Donations</strong> History') ?></div>
<?php if (!empty($subNav)): ?>
<?= $subNav ?>
<?php endif; ?>
<div class="panel-body">
<div style="margin-bottom:12px;">
<?= Html::a(
Yii::t('DonationsModule.base', 'Back to Donations Settings'),
$space->createUrl('/donations/settings'),
['class' => 'btn btn-default btn-sm']
) ?>
<?= Html::beginForm($space->createUrl('/donations/settings/reconcile-pending'), 'post', ['style' => 'display:inline-block;margin-left:8px;']) ?>
<?= Html::submitButton(
Yii::t('DonationsModule.base', 'Reconcile Pending Transactions'),
[
'class' => 'btn btn-primary btn-sm',
'data-confirm' => Yii::t('DonationsModule.base', 'Attempt reconciliation for pending transactions in this space now?'),
]
) ?>
<?= Html::endForm() ?>
</div>
<h4 style="margin-top:0;"><?= Yii::t('DonationsModule.base', 'Transactions') ?></h4>
<?php if (empty($transactions)): ?>
<div class="alert alert-info"><?= Yii::t('DonationsModule.base', 'No transactions found.') ?></div>
<?php else: ?>
<div class="table-responsive" style="margin-bottom:14px;">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>ID</th>
<th><?= Yii::t('DonationsModule.base', 'Provider') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Mode') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Status') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Amount') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Goal') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Checkout ID') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Payment ID') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Created') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($transactions as $transaction): ?>
<tr>
<td><?= (int)$transaction->id ?></td>
<td><?= Html::encode($transaction->provider) ?></td>
<td><?= Html::encode($transaction->mode) ?></td>
<td><?= Html::encode($transaction->status) ?></td>
<td><?= number_format((float)$transaction->amount, 2) ?> <?= Html::encode($transaction->currency) ?></td>
<td>#<?= (int)$transaction->goal_id ?></td>
<td style="max-width:180px;word-break:break-all;"><?= Html::encode((string)$transaction->provider_checkout_id) ?></td>
<td style="max-width:180px;word-break:break-all;"><?= Html::encode((string)$transaction->provider_payment_id) ?></td>
<td><?= Html::encode((string)$transaction->created_at) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<h4><?= Yii::t('DonationsModule.base', 'Subscriptions') ?></h4>
<?php if (empty($subscriptions)): ?>
<div class="alert alert-info"><?= Yii::t('DonationsModule.base', 'No subscriptions found.') ?></div>
<?php else: ?>
<div class="table-responsive" style="margin-bottom:14px;">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>ID</th>
<th><?= Yii::t('DonationsModule.base', 'Provider') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Status') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Amount') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Interval') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Subscription ID') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Created') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($subscriptions as $subscription): ?>
<tr>
<td><?= (int)$subscription->id ?></td>
<td><?= Html::encode($subscription->provider) ?></td>
<td><?= Html::encode($subscription->status) ?></td>
<td><?= number_format((float)$subscription->amount, 2) ?> <?= Html::encode($subscription->currency) ?></td>
<td><?= Html::encode((string)$subscription->interval_count) ?> <?= Html::encode((string)$subscription->interval_unit) ?></td>
<td style="max-width:220px;word-break:break-all;"><?= Html::encode((string)$subscription->provider_subscription_id) ?></td>
<td><?= Html::encode((string)$subscription->created_at) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<h4><?= Yii::t('DonationsModule.base', 'Recent Webhook Events (Global)') ?></h4>
<?php if (empty($webhookEvents)): ?>
<div class="alert alert-info" style="margin-bottom:0;"><?= Yii::t('DonationsModule.base', 'No webhook events found.') ?></div>
<?php else: ?>
<div class="table-responsive" style="margin-bottom:0;">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>ID</th>
<th><?= Yii::t('DonationsModule.base', 'Provider') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Event Type') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Event ID') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Processed') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Created') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($webhookEvents as $event): ?>
<tr>
<td><?= (int)$event->id ?></td>
<td><?= Html::encode($event->provider) ?></td>
<td><?= Html::encode((string)$event->event_type) ?></td>
<td style="max-width:220px;word-break:break-all;"><?= Html::encode((string)$event->provider_event_id) ?></td>
<td><?= (int)$event->is_processed === 1 ? Yii::t('DonationsModule.base', 'Yes') : Yii::t('DonationsModule.base', 'No') ?></td>
<td><?= Html::encode((string)$event->created_at) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>

635
views/settings/index.php Normal file
View File

@@ -0,0 +1,635 @@
<?php
use humhub\modules\donations\models\DonationGoal;
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 yii\bootstrap\ActiveForm;
use yii\helpers\Html;
use yii\helpers\Json;
/* @var string|null $subNav */
/* @var ProviderSettingsForm $providerForm */
/* @var DonationGoalForm $goalForm */
/* @var DonationGoal[] $goals */
/* @var DonationTransaction[] $transactions */
/* @var DonationSubscription[] $subscriptions */
/* @var DonationWebhookEvent[] $webhookEvents */
/* @var array $animalOptions */
/* @var array $animalGalleryImageMap */
/* @var string $activeTab */
/* @var bool $schemaReady */
$goalTypeOptions = DonationGoal::goalTypeOptions();
$isEditingGoal = !empty($goalForm->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]);
};
?>
<div class="panel panel-default">
<div class="panel-heading"><?= Yii::t('DonationsModule.base', '<strong>Donations</strong> Settings') ?></div>
<?php if (!empty($subNav)): ?>
<?= $subNav ?>
<?php endif; ?>
<div class="panel-body">
<?php if (!$schemaReady): ?>
<div class="alert alert-warning" style="margin-bottom:14px;">
<?= Yii::t('DonationsModule.base', 'Donations schema is not initialized for this environment yet. Use the Advanced tab to run setup before configuring providers, goals, or history.') ?>
</div>
<?php endif; ?>
<ul class="nav nav-tabs" style="margin-bottom:14px;">
<?php foreach ($tabs as $tabKey => $tabLabel): ?>
<li class="<?= $activeTab === $tabKey ? 'active' : '' ?>">
<?= Html::a(Html::encode($tabLabel), $tabUrl($tabKey)) ?>
</li>
<?php endforeach; ?>
</ul>
<?php if ($activeTab === 'general'): ?>
<div class="panel panel-default" style="margin-bottom:0;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Default Currency') ?></div>
<div class="panel-body">
<?php if (!$schemaReady): ?>
<div class="alert alert-info" style="margin-bottom:0;">
<?= Yii::t('DonationsModule.base', 'Run Donations setup from the Advanced tab to enable General settings.') ?>
</div>
<?php else: ?>
<?php $generalForm = ActiveForm::begin(); ?>
<?= Html::hiddenInput('active_tab', 'general') ?>
<?= $generalForm->errorSummary($providerForm, ['class' => 'alert alert-danger']) ?>
<div class="row">
<div class="col-md-4">
<?= $generalForm->field($providerForm, 'default_currency')->textInput(['maxlength' => 8]) ?>
</div>
<div class="col-md-4">
<?= $generalForm->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.')) ?>
</div>
<div class="col-md-8">
<?= $generalForm->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]".')) ?>
</div>
</div>
<?= Html::submitButton(Yii::t('DonationsModule.base', 'Save General Settings'), ['class' => 'btn btn-primary']) ?>
<?php ActiveForm::end(); ?>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($activeTab === 'goals'): ?>
<div class="panel panel-default" style="margin-bottom:14px;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Donation Goals') ?></div>
<div class="panel-body">
<?php if (!$schemaReady): ?>
<div class="alert alert-info" style="margin-bottom:0;">
<?= Yii::t('DonationsModule.base', 'Run Donations setup from the Advanced tab to manage goals.') ?>
</div>
<?php else: ?>
<?php if ($isEditingGoal): ?>
<div class="alert alert-info" style="margin-bottom:12px;">
<?= Yii::t('DonationsModule.base', 'You are editing an existing goal.') ?>
<?= Html::a(
Yii::t('DonationsModule.base', 'Cancel'),
$tabUrl('goals'),
['class' => 'btn btn-xs btn-default', 'style' => 'margin-left:8px;']
) ?>
</div>
<?php endif; ?>
<?php $goalActiveForm = ActiveForm::begin([
'options' => ['enctype' => 'multipart/form-data'],
]); ?>
<?= $goalActiveForm->errorSummary($goalForm, ['class' => 'alert alert-danger']) ?>
<?= $goalActiveForm->field($goalForm, 'id')->hiddenInput()->label(false) ?>
<div class="row">
<div class="col-md-3"><?= $goalActiveForm->field($goalForm, 'goal_type')->dropDownList($goalTypeOptions) ?></div>
<div class="col-md-3" id="donation-goal-animal-field" style="display:<?= $showAnimalTargetField ? 'block' : 'none' ?>;">
<?= $goalActiveForm->field($goalForm, 'target_animal_id')->dropDownList($animalOptions, ['prompt' => Yii::t('DonationsModule.base', 'Select animal')]) ?>
</div>
<div class="col-md-4"><?= $goalActiveForm->field($goalForm, 'title') ?></div>
<div class="col-md-2"><?= $goalActiveForm->field($goalForm, 'target_amount')->input('number', ['step' => '0.01', 'min' => '0']) ?></div>
</div>
<?= $goalActiveForm->field($goalForm, 'description')->textarea(['rows' => 3]) ?>
<div class="panel panel-default" style="margin-bottom:12px;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Goal Image') ?></div>
<div class="panel-body">
<?= $goalActiveForm->field($goalForm, 'imageGalleryPath')->hiddenInput()->label(false) ?>
<div id="donation-goal-current-preview" style="margin-bottom:10px;"></div>
<div id="donation-goal-gallery-wrapper" style="margin-bottom:12px;">
<div id="donation-goal-gallery-options" style="display:flex;flex-wrap:wrap;"></div>
<div id="donation-goal-gallery-empty" class="text-muted" style="display:none;">
<?= Yii::t('DonationsModule.base', 'No gallery images found for this animal. You can upload a new image below.') ?>
</div>
</div>
<?= $goalActiveForm->field($goalForm, 'imageFile')->fileInput(['accept' => 'image/*']) ?>
</div>
</div>
<?= $goalActiveForm->field($goalForm, 'is_active')->checkbox() ?>
<?= Html::submitButton(
$isEditingGoal ? Yii::t('DonationsModule.base', 'Update Goal') : Yii::t('DonationsModule.base', 'Create Goal'),
['class' => 'btn btn-primary']
) ?>
<?php ActiveForm::end(); ?>
<?php endif; ?>
</div>
</div>
<div class="panel panel-default" style="margin-bottom:0;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Existing Goals') ?></div>
<div class="panel-body">
<?php if (empty($goals)): ?>
<div class="alert alert-info" style="margin-bottom:0;"><?= Yii::t('DonationsModule.base', 'No donation goals configured yet.') ?></div>
<?php else: ?>
<div class="table-responsive" style="margin-bottom:0;">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th><?= Yii::t('DonationsModule.base', 'Title') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Type') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Progress') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Status') ?></th>
<th style="width:180px;"><?= Yii::t('DonationsModule.base', 'Actions') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($goals as $goal): ?>
<tr>
<td><?= Html::encode($goal->title) ?></td>
<td><?= Html::encode($goalTypeOptions[$goal->goal_type] ?? $goal->goal_type) ?></td>
<td><?= number_format((float)$goal->current_amount, 2) ?> / <?= number_format((float)$goal->target_amount, 2) ?> <?= Html::encode($goal->currency) ?></td>
<td>
<?php if ((int)$goal->is_active === 1): ?>
<span class="label label-success"><?= Yii::t('DonationsModule.base', 'Active') ?></span>
<?php else: ?>
<span class="label label-default"><?= Yii::t('DonationsModule.base', 'Inactive') ?></span>
<?php endif; ?>
</td>
<td>
<?= Html::a(
Yii::t('DonationsModule.base', 'Edit'),
$providerForm->contentContainer->createUrl('/donations/settings', [
'tab' => 'goals',
'goalId' => (int)$goal->id,
]),
['class' => 'btn btn-xs btn-primary']
) ?>
<?= Html::a(
Yii::t('DonationsModule.base', 'Delete'),
$providerForm->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?'),
]
) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($activeTab === 'payment-providers'): ?>
<div class="panel panel-default" style="margin-bottom:0;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Payment Providers') ?></div>
<div class="panel-body">
<?php if (!$schemaReady): ?>
<div class="alert alert-info" style="margin-bottom:0;">
<?= Yii::t('DonationsModule.base', 'Run Donations setup from the Advanced tab to manage payment providers.') ?>
</div>
<?php else: ?>
<?php $providerActiveForm = ActiveForm::begin(); ?>
<?= Html::hiddenInput('active_tab', 'payment-providers') ?>
<?= $providerActiveForm->errorSummary($providerForm, ['class' => 'alert alert-danger']) ?>
<div class="alert alert-info" style="margin-bottom:12px;">
<?= Yii::t('DonationsModule.base', 'Sandbox mode automatically uses sandbox credentials. Disable sandbox mode to use live credentials.') ?>
</div>
<div class="row" style="margin-bottom:8px;">
<div class="col-md-4" style="padding-top:6px;"><?= $providerActiveForm->field($providerForm, 'sandbox_mode')->checkbox() ?></div>
</div>
<div class="row">
<div class="col-md-6">
<div class="panel panel-default" style="margin-bottom:12px;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'PayPal') ?></div>
<div class="panel-body" style="padding-bottom:4px;">
<div class="row">
<div class="col-md-6"><?= $providerActiveForm->field($providerForm, 'paypal_enabled')->checkbox() ?></div>
<div class="col-md-6"><?= $providerActiveForm->field($providerForm, 'paypal_recurring_enabled')->checkbox() ?></div>
</div>
<div class="panel panel-default" style="margin-bottom:8px;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Sandbox Credentials') ?></div>
<div class="panel-body" style="padding-bottom:4px;">
<?= $providerActiveForm->field($providerForm, 'paypal_sandbox_client_id') ?>
<?= $providerActiveForm->field($providerForm, 'paypal_sandbox_client_secret') ?>
<?= $providerActiveForm->field($providerForm, 'paypal_sandbox_webhook_id') ?>
</div>
</div>
<div class="panel panel-default" style="margin-bottom:0;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Live Credentials') ?></div>
<div class="panel-body" style="padding-bottom:4px;">
<?= $providerActiveForm->field($providerForm, 'paypal_live_client_id') ?>
<?= $providerActiveForm->field($providerForm, 'paypal_live_client_secret') ?>
<?= $providerActiveForm->field($providerForm, 'paypal_live_webhook_id') ?>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default" style="margin-bottom:12px;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Stripe') ?></div>
<div class="panel-body" style="padding-bottom:4px;">
<div class="row">
<div class="col-md-6"><?= $providerActiveForm->field($providerForm, 'stripe_enabled')->checkbox() ?></div>
<div class="col-md-6"><?= $providerActiveForm->field($providerForm, 'stripe_recurring_enabled')->checkbox() ?></div>
</div>
<div class="panel panel-default" style="margin-bottom:8px;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Sandbox Credentials') ?></div>
<div class="panel-body" style="padding-bottom:4px;">
<?= $providerActiveForm->field($providerForm, 'stripe_sandbox_publishable_key') ?>
<?= $providerActiveForm->field($providerForm, 'stripe_sandbox_secret_key') ?>
<?= $providerActiveForm->field($providerForm, 'stripe_sandbox_webhook_secret') ?>
</div>
</div>
<div class="panel panel-default" style="margin-bottom:0;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Live Credentials') ?></div>
<div class="panel-body" style="padding-bottom:4px;">
<?= $providerActiveForm->field($providerForm, 'stripe_live_publishable_key') ?>
<?= $providerActiveForm->field($providerForm, 'stripe_live_secret_key') ?>
<?= $providerActiveForm->field($providerForm, 'stripe_live_webhook_secret') ?>
</div>
</div>
</div>
</div>
</div>
</div>
<?= Html::submitButton(Yii::t('DonationsModule.base', 'Save Provider Settings'), ['class' => 'btn btn-primary']) ?>
<?php ActiveForm::end(); ?>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($activeTab === 'donation-history'): ?>
<div class="panel panel-default" style="margin-bottom:14px;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Transactions') ?></div>
<div class="panel-body">
<?php if (!$schemaReady): ?>
<div class="alert alert-info" style="margin-bottom:0;">
<?= Yii::t('DonationsModule.base', 'Run Donations setup from the Advanced tab to view transaction history.') ?>
</div>
<?php elseif (empty($transactions)): ?>
<div class="alert alert-info" style="margin-bottom:0;"><?= Yii::t('DonationsModule.base', 'No transactions found.') ?></div>
<?php else: ?>
<div class="table-responsive" style="margin-bottom:0;">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>ID</th>
<th><?= Yii::t('DonationsModule.base', 'Provider') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Mode') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Status') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Amount') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Goal') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Checkout ID') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Payment ID') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Created') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($transactions as $transaction): ?>
<tr>
<td><?= (int)$transaction->id ?></td>
<td><?= Html::encode($transaction->provider) ?></td>
<td><?= Html::encode($transaction->mode) ?></td>
<td><?= Html::encode($transaction->status) ?></td>
<td><?= number_format((float)$transaction->amount, 2) ?> <?= Html::encode($transaction->currency) ?></td>
<td>#<?= (int)$transaction->goal_id ?></td>
<td style="max-width:180px;word-break:break-all;"><?= Html::encode((string)$transaction->provider_checkout_id) ?></td>
<td style="max-width:180px;word-break:break-all;"><?= Html::encode((string)$transaction->provider_payment_id) ?></td>
<td><?= Html::encode((string)$transaction->created_at) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<div class="panel panel-default" style="margin-bottom:14px;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Subscriptions') ?></div>
<div class="panel-body">
<?php if (!$schemaReady): ?>
<div class="alert alert-info" style="margin-bottom:0;">
<?= Yii::t('DonationsModule.base', 'Run Donations setup from the Advanced tab to view subscription history.') ?>
</div>
<?php elseif (empty($subscriptions)): ?>
<div class="alert alert-info" style="margin-bottom:0;"><?= Yii::t('DonationsModule.base', 'No subscriptions found.') ?></div>
<?php else: ?>
<div class="table-responsive" style="margin-bottom:0;">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>ID</th>
<th><?= Yii::t('DonationsModule.base', 'Provider') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Status') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Amount') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Interval') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Subscription ID') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Created') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($subscriptions as $subscription): ?>
<tr>
<td><?= (int)$subscription->id ?></td>
<td><?= Html::encode($subscription->provider) ?></td>
<td><?= Html::encode($subscription->status) ?></td>
<td><?= number_format((float)$subscription->amount, 2) ?> <?= Html::encode($subscription->currency) ?></td>
<td><?= Html::encode((string)$subscription->interval_count) ?> <?= Html::encode((string)$subscription->interval_unit) ?></td>
<td style="max-width:220px;word-break:break-all;"><?= Html::encode((string)$subscription->provider_subscription_id) ?></td>
<td><?= Html::encode((string)$subscription->created_at) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<div class="panel panel-default" style="margin-bottom:0;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Recent Webhook Events (Global)') ?></div>
<div class="panel-body">
<?php if (!$schemaReady): ?>
<div class="alert alert-info" style="margin-bottom:0;">
<?= Yii::t('DonationsModule.base', 'Run Donations setup from the Advanced tab to view webhook event history.') ?>
</div>
<?php elseif (empty($webhookEvents)): ?>
<div class="alert alert-info" style="margin-bottom:0;"><?= Yii::t('DonationsModule.base', 'No webhook events found.') ?></div>
<?php else: ?>
<div class="table-responsive" style="margin-bottom:0;">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>ID</th>
<th><?= Yii::t('DonationsModule.base', 'Provider') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Event Type') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Event ID') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Processed') ?></th>
<th><?= Yii::t('DonationsModule.base', 'Created') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($webhookEvents as $event): ?>
<tr>
<td><?= (int)$event->id ?></td>
<td><?= Html::encode($event->provider) ?></td>
<td><?= Html::encode((string)$event->event_type) ?></td>
<td style="max-width:220px;word-break:break-all;"><?= Html::encode((string)$event->provider_event_id) ?></td>
<td><?= (int)$event->is_processed === 1 ? Yii::t('DonationsModule.base', 'Yes') : Yii::t('DonationsModule.base', 'No') ?></td>
<td><?= Html::encode((string)$event->created_at) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php if ($activeTab === 'advanced'): ?>
<div class="well well-sm" style="margin-bottom:14px;">
<div style="font-weight:700;margin-bottom:6px;"><?= Yii::t('DonationsModule.base', 'Module Setup') ?></div>
<div style="margin-bottom:8px;">
<?= Yii::t('DonationsModule.base', 'Donations supports one-time and recurring contributions using PayPal and Stripe.') ?>
</div>
<div style="margin-bottom:10px;">
<?= Yii::t('DonationsModule.base', 'Run setup to apply pending Donations migrations and initialize defaults for this space.') ?>
</div>
<?= Html::a(
Yii::t('DonationsModule.base', 'Run Donations Setup'),
$providerForm->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?'),
]
) ?>
</div>
<?php if (!$schemaReady): ?>
<div class="alert alert-warning" style="margin-bottom:0;">
<?= Yii::t('DonationsModule.base', 'Donations schema is not initialized for this environment yet. Run setup above to continue.') ?>
</div>
<?php else: ?>
<div class="alert alert-info" style="margin-bottom:12px;">
<div><strong><?= Yii::t('DonationsModule.base', 'Stripe Webhook URL') ?>:</strong> <?= Html::encode($stripeWebhookUrl) ?></div>
<div><strong><?= Yii::t('DonationsModule.base', 'PayPal Webhook URL') ?>:</strong> <?= Html::encode($paypalWebhookUrl) ?></div>
</div>
<div class="panel panel-default" style="margin-bottom:12px;">
<div class="panel-heading" style="font-weight:600;"><?= Yii::t('DonationsModule.base', 'Webhook Simulation Tools') ?></div>
<div class="panel-body" style="padding-bottom:8px;">
<p style="margin-bottom:10px;">
<?= Yii::t('DonationsModule.base', 'Use these tools to test webhook processing locally after creating donation intents. Latest matching transaction in this space is used automatically.') ?>
</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<?= Html::beginForm($providerForm->contentContainer->createUrl('/donations/settings/simulate-stripe-webhook'), 'post', ['style' => 'display:inline-block;']) ?>
<?= Html::submitButton(Yii::t('DonationsModule.base', 'Simulate Stripe Checkout Completed'), ['class' => 'btn btn-default btn-sm']) ?>
<?= Html::endForm() ?>
<?= Html::beginForm($providerForm->contentContainer->createUrl('/donations/settings/simulate-paypal-webhook'), 'post', ['style' => 'display:inline-block;']) ?>
<?= Html::submitButton(Yii::t('DonationsModule.base', 'Simulate PayPal Capture Completed'), ['class' => 'btn btn-default btn-sm']) ?>
<?= Html::endForm() ?>
</div>
</div>
</div>
<div>
<?= Html::beginForm($providerForm->contentContainer->createUrl('/donations/settings/reconcile-pending'), 'post', ['style' => 'display:inline-block;margin-bottom:0;']) ?>
<?= Html::submitButton(
Yii::t('DonationsModule.base', 'Reconcile Pending Transactions'),
[
'class' => 'btn btn-primary btn-sm',
'data-confirm' => Yii::t('DonationsModule.base', 'Attempt reconciliation for pending transactions in this space now?'),
]
) ?>
<?= Html::endForm() ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php
$animalGalleryMapJson = Json::htmlEncode($animalGalleryImageMap);
$selectedImageUrl = trim((string)$goalForm->imageGalleryPath) !== ''
? trim((string)$goalForm->imageGalleryPath)
: trim((string)$goalForm->image_path);
$selectedImageUrlJson = Json::htmlEncode($selectedImageUrl);
$this->registerCss(<<<CSS
.donation-gallery-item {
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
}
.donation-gallery-item.is-active {
border-color: #337ab7;
box-shadow: 0 0 0 1px rgba(51, 122, 183, 0.25);
}
#donation-goal-current-preview img {
width: 120px;
height: 120px;
object-fit: cover;
border: 1px solid #ddd;
border-radius: 4px;
}
CSS
);
$this->registerJs(<<<JS
(function() {
var galleryMap = $animalGalleryMapJson;
var selectedImageUrl = $selectedImageUrlJson;
function setSelectedImage(url) {
$('#donationgoalform-imagegallerypath').val(url || '');
selectedImageUrl = url || '';
renderCurrentPreview();
renderGalleryOptions();
}
function renderCurrentPreview() {
var wrapper = $('#donation-goal-current-preview');
wrapper.empty();
if (!selectedImageUrl) {
wrapper.append('<span class="text-muted">No image selected.</span>');
return;
}
var image = $('<img>').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 type="button" class="donation-gallery-item" style="margin:0 8px 8px 0;padding:4px;"></button>');
button.attr('data-url', url);
if (selectedImageUrl === url) {
button.addClass('is-active');
}
button.append($('<img>').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
);