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