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