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