chore: bootstrap module from working instance and add install guide

This commit is contained in:
Kelin Rescue Hub
2026-04-09 14:18:10 -04:00
parent 97ad7da6f4
commit 6cda47760e
35 changed files with 6267 additions and 4 deletions

View File

@@ -0,0 +1,235 @@
<?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;
}
}