Files
donations/services/providers/PayPalWebhookService.php

414 lines
15 KiB
PHP

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