From ac870a4076ff17e911e85f9cc0282e3dfad8c15a Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Thu, 9 Jul 2026 15:34:28 +0000 Subject: [PATCH] Buchungsseite: Buchungs- und Verlaengerungsdatum des Abos aus Stripe Cashier spiegelt die Periode nicht lokal; neuer SubscriptionPeriodResolver holt start_date + current_period_end (Item-Ebene) gecacht, Fallback null. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Billing/SubscriptionPeriodResolver.php | 47 ++++++++++++ .../livewire/customer/bookings.blade.php | 22 ++++++ tests/Feature/Billing/BookingsPageTest.php | 71 +++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 app/Services/Billing/SubscriptionPeriodResolver.php diff --git a/app/Services/Billing/SubscriptionPeriodResolver.php b/app/Services/Billing/SubscriptionPeriodResolver.php new file mode 100644 index 00000000..1a65e4bb --- /dev/null +++ b/app/Services/Billing/SubscriptionPeriodResolver.php @@ -0,0 +1,47 @@ +stripe_id}:".($subscription->updated_at?->timestamp ?? 0); + + return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($subscription): ?array { + try { + $stripe = $subscription->asStripeSubscription(); + $renewsAt = $stripe->items->data[0]->current_period_end ?? null; + + if (! $renewsAt) { + return null; + } + + return [ + 'booked_at' => Carbon::createFromTimestamp($stripe->start_date), + 'renews_at' => Carbon::createFromTimestamp($renewsAt), + ]; + } catch (Throwable) { + return null; + } + }); + } +} diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index 74dcac7f..c059d331 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -14,6 +14,7 @@ use App\Services\Billing\CreditPricingService; use App\Services\Billing\CreditTopupService; use App\Services\Billing\ExtraPmPurchaseService; use App\Services\Billing\StripeCheckoutService; +use App\Services\Billing\SubscriptionPeriodResolver; use App\Services\Billing\VatResolver; use Flux\Flux; use Livewire\Attributes\Layout; @@ -257,6 +258,12 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte $currentInterval = $subscription->stripe_price === $currentPlan->stripe_price_id_yearly ? 'yearly' : 'monthly'; } + // Buchungs- und Verlängerungsdatum des laufenden Abos (Stripe spiegelt + // die Periode nicht lokal, daher gecachter Abruf; null bei Fehler). + $subscriptionPeriod = $subscription + ? app(SubscriptionPeriodResolver::class)->resolve($subscription) + : null; + // Vorgemerkter Tarifwechsel (Downgrade zum Periodenende): lokale // Spiegelung der Stripe-Schedule-Vormerkung für die Anzeige. $scheduledChangePlan = null; @@ -277,6 +284,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte 'currentPlan' => $currentPlan, 'currentInterval' => $currentInterval, 'subscription' => $subscription, + 'subscriptionPeriod' => $subscriptionPeriod, 'scheduledChangePlan' => $scheduledChangePlan, 'scheduledChangeAt' => $subscription?->scheduled_change_at ? \Illuminate\Support\Carbon::parse($subscription->scheduled_change_at) @@ -531,6 +539,20 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('max. :limit Veröffentlichungen pro Tag', ['limit' => $currentPlan->daily_limit]) }} @endif

+ @if ($subscriptionPeriod) +
+ + + {{ __('Gebucht am :date', ['date' => $subscriptionPeriod['booked_at']->format('d.m.Y')]) }} + + @unless ($subscription?->onGracePeriod()) + + + {{ __('Verlängerung am :date', ['date' => $subscriptionPeriod['renews_at']->format('d.m.Y')]) }} + + @endunless +
+ @endif @if ($subscription?->onGracePeriod())

{{ __('Gekündigt — läuft am :date aus.', ['date' => $subscription->ends_at?->format('d.m.Y')]) }} diff --git a/tests/Feature/Billing/BookingsPageTest.php b/tests/Feature/Billing/BookingsPageTest.php index 89f3786e..d1921005 100644 --- a/tests/Feature/Billing/BookingsPageTest.php +++ b/tests/Feature/Billing/BookingsPageTest.php @@ -12,14 +12,25 @@ use App\Models\User; use App\Models\UserPaymentOption; use App\Services\Billing\CreditWalletService; use App\Services\Billing\StripeCheckoutService; +use App\Services\Billing\SubscriptionPeriodResolver; use Database\Seeders\LegalDocumentSeeder; use Database\Seeders\RolesAndPermissionsSeeder; +use Illuminate\Support\Carbon; use Livewire\Volt\Volt as LivewireVolt; use Tests\TestCase; beforeEach(function (): void { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); + + // Standard: kein echter Stripe-Abruf fürs Buchungs-/Verlängerungsdatum. + // Die Datums-Tests binden bei Bedarf einen eigenen Stub. + $this->instance( + SubscriptionPeriodResolver::class, + Mockery::mock(SubscriptionPeriodResolver::class, function ($mock) { + $mock->shouldReceive('resolve')->andReturnNull(); + }) + ); }); function bookingsTestCustomer(): User @@ -448,3 +459,63 @@ test('a scheduled plan change is shown on the current plan card', function () { ->assertSee('Tarifwechsel zu Kleiner Tarif am') ->assertSee(now()->addDays(9)->format('d.m.Y')); }); + +test('the current plan card shows booking and renewal dates from stripe', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + BillingAddress::factory()->create(['user_id' => $user->id]); + + $plan = Plan::factory()->create([ + 'name' => 'Pro', + 'slug' => 'pro', + 'monthly_price_cents' => 9900, + 'stripe_price_id_monthly' => 'price_test_pro_m', + ]); + subscribeUserToPlan($user, $plan); + + // Stripe-Periode deterministisch stubben — kein echter API-Abruf im Test. + $this->instance( + SubscriptionPeriodResolver::class, + Mockery::mock(SubscriptionPeriodResolver::class, function ($mock) { + $mock->shouldReceive('resolve')->andReturn([ + 'booked_at' => Carbon::parse('2026-07-09'), + 'renews_at' => Carbon::parse('2026-08-09'), + ]); + }) + ); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->assertSee('Aktueller Tarif') + ->assertSee('Gebucht am 09.07.2026') + ->assertSee('Verlängerung am 09.08.2026'); +}); + +test('the current plan card omits the dates when the stripe period is unavailable', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + BillingAddress::factory()->create(['user_id' => $user->id]); + + $plan = Plan::factory()->create([ + 'name' => 'Pro', + 'slug' => 'pro', + 'stripe_price_id_monthly' => 'price_test_pro_m2', + ]); + subscribeUserToPlan($user, $plan); + + // Stripe nicht erreichbar → Resolver liefert null; Seite rendert trotzdem. + $this->instance( + SubscriptionPeriodResolver::class, + Mockery::mock(SubscriptionPeriodResolver::class, function ($mock) { + $mock->shouldReceive('resolve')->andReturnNull(); + }) + ); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->assertSee('Aktueller Tarif') + ->assertDontSee('Gebucht am') + ->assertDontSee('Verlängerung am'); +});