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