chore: bootstrap module from working instance and add install guide
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user