Files
donations/controllers/SettingsController.php

617 lines
25 KiB
PHP

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