From 9be473162aaa049d0725329219801a3aa149d496 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 20 Jul 2026 10:50:47 +0000 Subject: [PATCH] =?UTF-8?q?billing:=20Abo-K=C3=BCndigungen=20aus=20dem=20S?= =?UTF-8?q?tripe-Portal=20im=20Aktivit=C3=A4ts-Verlauf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kündigung zum Periodenende, deren Rücknahme (customer.subscription.updated, Transition über previous_attributes.cancel_at_period_end) und das endgültige Abo-Ende (customer.subscription.deleted) erscheinen jetzt als neuer Typ "Abo" unter Admin ▸ Reports ▸ Verlauf — bisher war dort nur die Zahlung sichtbar, die Portal-Kündigung blieb unsichtbar. Co-Authored-By: Claude Fable 5 --- app/Enums/ActivityType.php | 4 +- app/Listeners/ProcessStripeWebhook.php | 96 ++++++++++++++++++- .../Billing/StripeWebhookProcessingTest.php | 82 ++++++++++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index bdd0664b..3cbee23d 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -19,6 +19,7 @@ enum ActivityType: string case WithdrawalRequest = 'withdrawal_request'; case TerminationRequest = 'termination_request'; case NewsletterSubscription = 'newsletter_subscription'; + case SubscriptionChange = 'subscription_change'; public function label(): string { @@ -33,6 +34,7 @@ enum ActivityType: string self::WithdrawalRequest => 'Widerruf', self::TerminationRequest => 'Kündigung', self::NewsletterSubscription => 'Newsletter', + self::SubscriptionChange => 'Abo', }; } @@ -47,7 +49,7 @@ enum ActivityType: string self::PressReleaseStatus => 'blue', self::BookingPaid, self::CreditTopup => 'amber', self::ContactRequest, self::NewsletterSubscription => 'cyan', - self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest => 'red', + self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest, self::SubscriptionChange => 'red', }; } } diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 21d75f02..65e36093 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -22,6 +22,7 @@ use App\Services\Billing\CreditTopupService; use App\Services\Billing\InvoiceNumberGenerator; use App\Services\PressRelease\PressReleaseService; use Illuminate\Database\UniqueConstraintViolationException; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; @@ -58,11 +59,104 @@ class ProcessStripeWebhook 'checkout.session.completed', 'checkout.session.async_payment_succeeded' => $this->fulfillCheckout($event->payload['data']['object'] ?? []), 'checkout.session.async_payment_failed' => $this->logFailedAsyncPayment($event->payload['data']['object'] ?? []), - 'customer.subscription.updated' => $this->clearAppliedScheduledChange($event->payload['data']['object'] ?? []), + 'customer.subscription.updated' => $this->handleSubscriptionUpdated($event->payload['data'] ?? []), + 'customer.subscription.deleted' => $this->recordSubscriptionEnded($event->payload['data']['object'] ?? []), default => null, }; } + /** + * @param array $data + */ + private function handleSubscriptionUpdated(array $data): void + { + $this->clearAppliedScheduledChange($data['object'] ?? []); + $this->recordCancellationChange($data); + } + + /** + * Kündigung zum Periodenende (bzw. deren Rücknahme) im Stripe-Kundenportal: + * Cashier pflegt `ends_at`, hier kommt der Eintrag in den Aktivitäts-Verlauf. + * Die Transition steckt in `previous_attributes.cancel_at_period_end`. + * + * @param array $data + */ + private function recordCancellationChange(array $data): void + { + $subscription = $data['object'] ?? []; + $previous = $data['previous_attributes'] ?? []; + + if (! array_key_exists('cancel_at_period_end', $previous)) { + return; + } + + $cancelled = (bool) ($subscription['cancel_at_period_end'] ?? false); + + if ($cancelled === (bool) $previous['cancel_at_period_end']) { + return; + } + + $user = Cashier::findBillable($subscription['customer'] ?? null); + + if (! $user instanceof User) { + return; + } + + $endsAt = filled($subscription['cancel_at'] ?? null) + ? Carbon::createFromTimestamp((int) $subscription['cancel_at'])->timezone(config('app.timezone')) + : null; + + $description = $cancelled + ? sprintf( + 'Abo gekündigt — läuft am %s aus. %s (%s)', + $endsAt?->format('d.m.Y') ?? 'Periodenende', + $user->name, + $user->email, + ) + : sprintf('Abo-Kündigung zurückgenommen. %s (%s)', $user->name, $user->email); + + $this->activityLog->record( + ActivityType::SubscriptionChange, + $description, + user: $user, + subject: $this->localSubscription($subscription['id'] ?? null), + meta: array_filter([ + 'stripe_subscription_id' => $subscription['id'] ?? null, + 'cancel_at' => $endsAt?->toDateTimeString(), + 'cancelled' => $cancelled, + ], fn ($value): bool => $value !== null), + ); + } + + /** + * Abo endgültig beendet (Periodenende erreicht oder Sofort-Kündigung). + * + * @param array $subscription + */ + private function recordSubscriptionEnded(array $subscription): void + { + $user = Cashier::findBillable($subscription['customer'] ?? null); + + if (! $user instanceof User) { + return; + } + + $this->activityLog->record( + ActivityType::SubscriptionChange, + sprintf('Abo beendet. %s (%s)', $user->name, $user->email), + user: $user, + subject: $this->localSubscription($subscription['id'] ?? null), + meta: array_filter(['stripe_subscription_id' => $subscription['id'] ?? null]), + ); + } + + private function localSubscription(?string $stripeId): ?Subscription + { + return $stripeId + ? Subscription::query()->where('stripe_id', $stripeId)->first() + : null; + } + /** * Vorgemerkter Tarifwechsel (Downgrade zum Periodenende): Sobald der * Stripe Subscription Schedule die neue Phase aktiviert, meldet diff --git a/tests/Feature/Billing/StripeWebhookProcessingTest.php b/tests/Feature/Billing/StripeWebhookProcessingTest.php index e89c5a73..f0eae733 100644 --- a/tests/Feature/Billing/StripeWebhookProcessingTest.php +++ b/tests/Feature/Billing/StripeWebhookProcessingTest.php @@ -1,9 +1,11 @@ fresh()->scheduled_stripe_price)->toBe('price_new'); }); + +test('a portal cancellation at period end is recorded in the activity log', function () { + $user = User::factory()->create(['stripe_id' => 'cus_test_1']); + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_cancel_1', + 'stripe_status' => 'active', + 'stripe_price' => 'price_x', + 'quantity' => 1, + ]); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'id' => 'sub_cancel_1', + 'customer' => 'cus_test_1', + 'cancel_at_period_end' => true, + 'cancel_at' => now()->addMonth()->timestamp, + ], + 'previous_attributes' => ['cancel_at_period_end' => false], + ], + ])); + + $activityEvent = ActivityEvent::sole(); + expect($activityEvent->type)->toBe(ActivityType::SubscriptionChange) + ->and($activityEvent->user_id)->toBe($user->id) + ->and($activityEvent->description)->toContain('Abo gekündigt') + ->and($activityEvent->description)->toContain(now()->addMonth()->format('d.m.Y')); +}); + +test('resuming a cancelled subscription is recorded in the activity log', function () { + User::factory()->create(['stripe_id' => 'cus_test_1']); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'id' => 'sub_cancel_1', + 'customer' => 'cus_test_1', + 'cancel_at_period_end' => false, + ], + 'previous_attributes' => ['cancel_at_period_end' => true], + ], + ])); + + expect(ActivityEvent::sole()->description)->toContain('Abo-Kündigung zurückgenommen'); +}); + +test('a subscription update without cancellation transition records no activity', function () { + User::factory()->create(['stripe_id' => 'cus_test_1']); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => [ + 'object' => [ + 'id' => 'sub_cancel_1', + 'customer' => 'cus_test_1', + 'cancel_at_period_end' => false, + 'items' => ['data' => [['price' => ['id' => 'price_x']]]], + ], + 'previous_attributes' => ['items' => []], + ], + ])); + + expect(ActivityEvent::count())->toBe(0); +}); + +test('a fully ended subscription is recorded in the activity log', function () { + $user = User::factory()->create(['stripe_id' => 'cus_test_1']); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['id' => 'sub_cancel_1', 'customer' => 'cus_test_1']], + ])); + + $activityEvent = ActivityEvent::sole(); + expect($activityEvent->description)->toContain('Abo beendet') + ->and($activityEvent->user_id)->toBe($user->id); +});