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 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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue