chore: bootstrap module from working instance and add install guide
This commit is contained in:
435
Events.php
Normal file
435
Events.php
Normal 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
63
INSTALL.md
Normal 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
38
Module.php
Normal 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
51
README.md
Normal 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
|
||||
@@ -1,4 +0,0 @@
|
||||
# Donations module for HumHub
|
||||
|
||||
Enables PayPal & Stripe Payments
|
||||
Integrates into animal-management module
|
||||
20
config.php
Normal file
20
config.php
Normal 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']],
|
||||
],
|
||||
];
|
||||
797
controllers/DonationsController.php
Normal file
797
controllers/DonationsController.php
Normal 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'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
616
controllers/SettingsController.php
Normal file
616
controllers/SettingsController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
14
events/DonationSettlementEvent.php
Normal file
14
events/DonationSettlementEvent.php
Normal 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 = [];
|
||||
}
|
||||
157
migrations/m260404_000000_initial.php
Normal file
157
migrations/m260404_000000_initial.php
Normal 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
46
models/DonationBlock.php
Normal 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
69
models/DonationGoal.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
models/DonationProviderConfig.php
Normal file
44
models/DonationProviderConfig.php
Normal 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;
|
||||
}
|
||||
}
|
||||
45
models/DonationSubscription.php
Normal file
45
models/DonationSubscription.php
Normal 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;
|
||||
}
|
||||
}
|
||||
52
models/DonationTransaction.php
Normal file
52
models/DonationTransaction.php
Normal 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;
|
||||
}
|
||||
}
|
||||
43
models/DonationWebhookEvent.php
Normal file
43
models/DonationWebhookEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
271
models/forms/DonationGoalForm.php
Normal file
271
models/forms/DonationGoalForm.php
Normal 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;
|
||||
}
|
||||
}
|
||||
209
models/forms/ProviderSettingsForm.php
Normal file
209
models/forms/ProviderSettingsForm.php
Normal 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
15
module.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
notifications/DonationNotificationCategory.php
Normal file
21
notifications/DonationNotificationCategory.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
114
notifications/DonationRefundedNotification.php
Normal file
114
notifications/DonationRefundedNotification.php
Normal 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);
|
||||
}
|
||||
}
|
||||
114
notifications/DonationSucceededNotification.php
Normal file
114
notifications/DonationSucceededNotification.php
Normal 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
30
permissions/Donate.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
33
permissions/ManageDonations.php
Normal file
33
permissions/ManageDonations.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
113
services/DonationAnimalIntegrationService.php
Normal file
113
services/DonationAnimalIntegrationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
146
services/DonationNotificationService.php
Normal file
146
services/DonationNotificationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
108
services/DonationSettlementService.php
Normal file
108
services/DonationSettlementService.php
Normal 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 : [];
|
||||
}
|
||||
}
|
||||
81
services/ModuleSetupService.php
Normal file
81
services/ModuleSetupService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
413
services/providers/PayPalWebhookService.php
Normal file
413
services/providers/PayPalWebhookService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
618
services/providers/PaymentGatewayService.php
Normal file
618
services/providers/PaymentGatewayService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
134
services/providers/ProviderCredentialResolver.php
Normal file
134
services/providers/ProviderCredentialResolver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
235
services/providers/StripeWebhookService.php
Normal file
235
services/providers/StripeWebhookService.php
Normal 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
341
views/donations/index.php
Normal 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
146
views/settings/history.php
Normal 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
635
views/settings/index.php
Normal 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
|
||||
);
|
||||
Reference in New Issue
Block a user