236 lines
9.1 KiB
PHP
236 lines
9.1 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 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;
|
|
}
|
|
}
|