billing: Abo-Kündigungen aus dem Stripe-Portal im Aktivitäts-Verlauf

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-20 10:50:47 +00:00
parent 7971656025
commit 9be473162a
3 changed files with 180 additions and 2 deletions

View file

@ -19,6 +19,7 @@ enum ActivityType: string
case WithdrawalRequest = 'withdrawal_request'; case WithdrawalRequest = 'withdrawal_request';
case TerminationRequest = 'termination_request'; case TerminationRequest = 'termination_request';
case NewsletterSubscription = 'newsletter_subscription'; case NewsletterSubscription = 'newsletter_subscription';
case SubscriptionChange = 'subscription_change';
public function label(): string public function label(): string
{ {
@ -33,6 +34,7 @@ enum ActivityType: string
self::WithdrawalRequest => 'Widerruf', self::WithdrawalRequest => 'Widerruf',
self::TerminationRequest => 'Kündigung', self::TerminationRequest => 'Kündigung',
self::NewsletterSubscription => 'Newsletter', self::NewsletterSubscription => 'Newsletter',
self::SubscriptionChange => 'Abo',
}; };
} }
@ -47,7 +49,7 @@ enum ActivityType: string
self::PressReleaseStatus => 'blue', self::PressReleaseStatus => 'blue',
self::BookingPaid, self::CreditTopup => 'amber', self::BookingPaid, self::CreditTopup => 'amber',
self::ContactRequest, self::NewsletterSubscription => 'cyan', self::ContactRequest, self::NewsletterSubscription => 'cyan',
self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest => 'red', self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest, self::SubscriptionChange => 'red',
}; };
} }
} }

View file

@ -22,6 +22,7 @@ use App\Services\Billing\CreditTopupService;
use App\Services\Billing\InvoiceNumberGenerator; use App\Services\Billing\InvoiceNumberGenerator;
use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\PressReleaseService;
use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
@ -58,11 +59,104 @@ class ProcessStripeWebhook
'checkout.session.completed', 'checkout.session.completed',
'checkout.session.async_payment_succeeded' => $this->fulfillCheckout($event->payload['data']['object'] ?? []), 'checkout.session.async_payment_succeeded' => $this->fulfillCheckout($event->payload['data']['object'] ?? []),
'checkout.session.async_payment_failed' => $this->logFailedAsyncPayment($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, default => null,
}; };
} }
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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 * Vorgemerkter Tarifwechsel (Downgrade zum Periodenende): Sobald der
* Stripe Subscription Schedule die neue Phase aktiviert, meldet * Stripe Subscription Schedule die neue Phase aktiviert, meldet

View file

@ -1,9 +1,11 @@
<?php <?php
use App\Enums\ActivityType;
use App\Enums\InvoiceStatus; use App\Enums\InvoiceStatus;
use App\Enums\SinglePurchaseStatus; use App\Enums\SinglePurchaseStatus;
use App\Listeners\ProcessStripeWebhook; use App\Listeners\ProcessStripeWebhook;
use App\Mail\BookingPaymentConfirmed; use App\Mail\BookingPaymentConfirmed;
use App\Models\ActivityEvent;
use App\Models\BillingAddress; use App\Models\BillingAddress;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\SinglePurchase; use App\Models\SinglePurchase;
@ -354,3 +356,83 @@ test('a subscription update to a different price keeps the scheduled downgrade',
expect($subscription->fresh()->scheduled_stripe_price)->toBe('price_new'); expect($subscription->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);
});