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:
parent
7971656025
commit
9be473162a
3 changed files with 180 additions and 2 deletions
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
* Stripe Subscription Schedule die neue Phase aktiviert, meldet
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
use App\Listeners\ProcessStripeWebhook;
|
||||
use App\Mail\BookingPaymentConfirmed;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\BillingAddress;
|
||||
use App\Models\Invoice;
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue